openvibers/openvibe.chat Commits
Chat serves from PostgreSQL (plan T3, the engine move): openvibe-sdk/db everywhere, PGlite for dev and npm test
Changes
@@@ -44,23 +44,28 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes thchannel moderation settings and moderators, bans and IP rules, follows, cosmetics and tags, sitesettings and anon numbers are read through it; coins, AI viewers, arena, media queue, hardware,pastes, translation, PowerChat, notifications, viewer counts and the IP log are effects it asks-- Live for. Reads are cached projections (SQLite `ctx_*` tables kept complete by paged syncs; TTL++ Live for. Reads are cached projections (`ctx_*` tables kept complete by paged syncs; TTLcaches that serve stale while refreshing) warmed when a socket joins, so a chat message neverwaits on a network read. Live pushes invalidations when it changes cached data.- **Live's side** is `docs/live-patch.diff` (applies to Live `main` with `git apply`):`/internal/chat-context/*` and `/internal/chat-effects/*`, and `CHAT_AUTHORITY=chat`, which stopsLive's chat server and routes and turns `require('./chat/chat-server')` into a proxy that forwardsLive's own chat calls here (`POST /internal/live/calls`).--- **Own database** (`CHAT_DB_PATH` today, systemd `StateDirectory=openvibe-chat`) with Live's tables and-- ids, the Network subject on new rows, a transactional events outbox and a read mirror back into Live.-- The move to PostgreSQL (ADR-035, plan T3) is prepared here: `migrations/0001_initial.sql` is the whole-- schema on PostgreSQL (with the twelve Live read-mirror triggers, gated on `ov.mirror_skip`), and-- `scripts/import-sqlite-to-pg.js` moves the SQLite data over with a per-table report. In production that-- needs `DATABASE_URL` (runtime, through PgBouncer) and `DATABASE_DIRECT_URL` (the owner, for migrations);-- `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters on Valkey instead of this process.++- **Own database on PostgreSQL** (ADR-035, plan T3) with Live's tables and ids, the Network subject on new++ rows, a transactional events outbox and a read mirror back into Live. `server/db/database.js` serves++ through `openvibe-sdk/db`: production needs `DATABASE_URL` (the runtime role, DML only, through++ PgBouncer) and `DATABASE_DIRECT_URL` (the owner, which applies `migrations/` at boot); without++ `DATABASE_URL` outside production it runs on an embedded PGlite in `data/pglite` (`CHAT_PGLITE_DIR`).++ `migrations/0001_initial.sql` is the whole schema (timestamps stay SQLite-format text through++ `ov_now()`/`datetime()`); its triggers on the twelve Chat tables queue Live's read mirror++ (`live_mirror_outbox`), skipped where a transaction sets `ov.mirror_skip = '1'` (the importers).++ `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 backs Chat's database up first), idempotent, never drops a row of Chat's tables-- (`import_hold`), reports counts per table.++ `--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.- **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,`chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their onlywriter, there is no authority switch any more. Live's current release's writers still call Chat over@@@ -160,7 +165,7 @@ Chat subscribes (consumer `chat`) to these topics (and `network.user.token_validcrash are the same card. A redeploy of a head already announced (a rollback to it) says nothing.A release event older than 6 hours (an operator replay) is `ignored:stale`.- **Exactly once.** The openvibe-sdk inbox (`chat_event_inbox`, pruned after 35 days) claims-- `(chat, event_id)` in the same SQLite transaction as the change, and broadcasts run only after it++ `(chat, event_id)` in the same transaction as the change, and broadcasts run only after itcommits. A failure answers 500 and rolls both back; Events retries.- **Signature v2 only** (`parseDelivery` with `requireV2`): HMAC over `"<t>.<raw body>"` under`CHAT_EVENTS_SECRET`, `t` within ±300 s. A bad, stale, v1-only or unsigned delivery is 401; no secret@@@ -357,6 +362,8 @@ npm installcp .env.example .env # OV_LIVE_INTERNAL_URL, Network URLs, OV_OAUTH_CLIENT_SECRET, SOUNDS_PATHnpm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,sounds}, /health, /ready (CHAT_CALLS=1: /ws/call, /api/streams/…)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 writesnode scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"node scripts/mirror-flush.js # rollback helper: push queued mirror rows to Live@@@ -433,7 +440,7 @@ server/prefs/ chat preferences in the Network user module chat.prefserver/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycleserver/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service tokenserver/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)--server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)++server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)scripts/ import-from-live, parity-check, parity, mirror-flush, migrate-chat-preferences, subscribe-events, n-1-recorddocs/ cutover.md, calls-cutover.md, parity.md, live-patch.diff, capabilities-proposal/```@@@ -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, better-sqlite3 (moving to PostgreSQL + Valkey, plan T3: migrations/0001_initial.sql is the whole schema on PostgreSQL, scripts/import-sqlite-to-pg.js moves the data, openvibe-sdk/db + /valkey; needs DATABASE_URL/DATABASE_DIRECT_URL/VALKEY_URL)",++ "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)","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.71.0",@@@ -179,7 +179,7 @@ Live becomes the chat authority again on its own tables, which the mirror kept cto Live, which answers 503 until step 3 — clients retry).2. Make sure everything Chat wrote is in Live: `curl -s 127.0.0.1:4400/ready | jq .mirror.pending`→ 0. If the service cannot run: `cd /opt/openvibe.chat && sudo -u ubuntu env $(sudo cat-- /etc/openvibe/chat.env | xargs) CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db node scripts/mirror-flush.js`++ /etc/openvibe/chat.env | xargs) node scripts/mirror-flush.js` (the env names Chat's database: `DATABASE_URL`)(exit 0 = drained). Live accepts mirror writes only while it still has the flag, so do thisbefore step 3.3. Remove `CHAT_AUTHORITY` from `/etc/openvibe/live.env`, restart Live. At boot Live applies any@@@ -248,23 +248,39 @@ creates, so `npm run test:pg` rehearses the same shape.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 prints one line per table (rows + checksum) and records the whole report in `import_runs`. It--runs with `ov.mirror_skip = '1'`, so the imported rows (which came from Live) are never queued back into--Live's read mirror. A problem (a source column with no target, a verification mismatch) makes it exit 1.----**Switch.** Drain Chat (stop the unit), take a final copy, and import it as the owner:---- node scripts/import-sqlite-to-pg.js --sqlite /var/lib/openvibe-chat/chat.db --url "$DATABASE_DIRECT_URL"----Set `DATABASE_URL` and `DATABASE_DIRECT_URL` in `/etc/openvibe/chat.env`, start the new release, and check--`GET /ready`: `db` ok (its `max_message_id` is the pre-cutover `SELECT MAX(id) FROM chat_messages`), the--optional `valkey` check ok when `VALKEY_URL` is set (skipped otherwise), and `mirror.pending` 0 once the--relay has flushed. Compare a few counts against the source with `scripts/parity-check.js` (repointed at--PostgreSQL) before retiring the SQLite file.----**Rollback** is the previous release plus the retained SQLite file: stop Chat, restore `DATABASE_URL` to--unset (the old release reads `CHAT_DB_PATH`), restore the copy taken before the switch, and start the old--release. The Live read mirror keeps Live's copy current throughout, so a rollback loses nothing Live has--already seen. Keep the SQLite file read-only for the rollback window; do not delete it until the--PostgreSQL deploy has served a full release.++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 Live's read mirror, and ends with++`ANALYZE` as the owner (the runtime role's ANALYZE is a no-op; without statistics the history page plans a++Sort). A problem (a source column with no target, a verification mismatch) makes it exit 1.++++**Switch.**++1. On the old release: `curl -s 127.0.0.1:4400/ready | jq .mirror.pending` → 0 (Live has every change), then++ stop the unit (drained: nothing writes the file any more).++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`), `mirror.pending`++ back to 0 once the relay has flushed the first writes.++5. From the deployed commit, `npm run n-1:record` and commit `test/fixtures/n-1`: the N-1 test then replays++ this release's PostgreSQL statements again (it reports itself skipped until then).++++**Rollback** is the previous release plus the retained SQLite file: stop Chat, unset `DATABASE_URL` /++`DATABASE_DIRECT_URL` (the old release reads `CHAT_DB_PATH`, which the unit still sets), and start the old++release on `chat.db.pre-pg`. The twelve chat tables Live mirrors lose nothing Live has already seen; rows++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.@@@ -33,7 +33,7 @@ CREATE FUNCTION instr(t text, sub text) RETURNS integer LANGUAGE sql IMMUTABLE A-- ── 1. Chat-owned ──────────────────────────────────────────────CREATE TABLE chat_messages (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,stream_id bigint, -- Live stream (session) iduser_id bigint, -- Live user id; NULL for anonanon_id text COLLATE "C", -- 'anon12345' format@@@ -46,7 +46,7 @@ CREATE TABLE chat_messages (is_filtered bigint DEFAULT 0,reply_to_id bigint REFERENCES chat_messages(id) ON DELETE SET NULL,source_platform text COLLATE "C",-- deleted_by bigint,++ deleted_by text COLLATE "C",deleted_at text COLLATE "C",auto_delete_at text COLLATE "C",timestamp text COLLATE "C" DEFAULT ov_now(),@@@ -68,7 +68,7 @@ CREATE INDEX idx_chat_page_live ON chat_messages (id DESC) WHERE is_deleted = 0;CREATE INDEX idx_chat_anon ON chat_messages(anon_id);CREATE TABLE dm_conversations (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,name text COLLATE "C",is_group bigint DEFAULT 0,created_by bigint NOT NULL,@@@ -77,7 +77,7 @@ CREATE TABLE dm_conversations ();CREATE TABLE dm_participants (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,conversation_id bigint NOT NULL,user_id bigint NOT NULL,last_read_at text COLLATE "C" DEFAULT '1970-01-01 00:00:00',@@@ -90,7 +90,7 @@ CREATE INDEX idx_dm_participants_conv ON dm_participants(conversation_id);CREATE INDEX idx_dm_participants_user ON dm_participants(user_id);CREATE TABLE dm_messages (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,conversation_id bigint NOT NULL,sender_id bigint NOT NULL,message text COLLATE "C" NOT NULL,@@@ -102,7 +102,7 @@ CREATE INDEX idx_dm_messages_conv ON dm_messages(conversation_id, created_at);CREATE INDEX idx_dm_messages_sender ON dm_messages(sender_id);CREATE TABLE dm_blocks (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,blocker_id bigint NOT NULL,blocked_id bigint NOT NULL,created_at text COLLATE "C" DEFAULT ov_now(),@@@ -123,7 +123,7 @@ CREATE TABLE tts_voice_overrides ();CREATE TABLE channel_sounds (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,channel_owner_id bigint NOT NULL, -- streamer whose channel this sound belongs tocommand text COLLATE "C" NOT NULL, -- trigger word (without leading '!'), lowercasedurl text COLLATE "C" NOT NULL, -- on-disk path of the audio file@@@ -142,7 +142,7 @@ CREATE INDEX idx_channel_sounds_owner ON channel_sounds(channel_owner_id);CREATE INDEX idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);CREATE TABLE relay_users (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,platform text COLLATE "C" NOT NULL,username text COLLATE "C" NOT NULL,display_name text COLLATE "C",@@@ -153,7 +153,7 @@ CREATE TABLE relay_users ();CREATE TABLE hidden_relay_users (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,channel_id bigint,platform text COLLATE "C" NOT NULL,external_username text COLLATE "C" NOT NULL,@@@ -165,7 +165,7 @@ CREATE TABLE hidden_relay_users (CREATE INDEX idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform);CREATE TABLE pending_ip_messages (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,channel_id bigint NOT NULL,stream_id bigint,ip_address text COLLATE "C" NOT NULL,@@@ -188,9 +188,9 @@ CREATE TABLE stream_first_chats (CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id);CREATE TABLE moderation_actions (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,scope_type text COLLATE "C" NOT NULL DEFAULT 'site',-- scope_id bigint,++ scope_id text COLLATE "C",actor_user_id bigint,target_user_id bigint,action_type text COLLATE "C" NOT NULL,@@@ -205,7 +205,7 @@ CREATE INDEX idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);-- ── 2. The six chat-target tables (Chat is their only writer since C-04) ──CREATE TABLE channel_moderators (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,channel_id bigint NOT NULL,user_id bigint NOT NULL,added_by bigint NOT NULL,@@@ -263,7 +263,7 @@ CREATE TABLE channel_moderation_settings ();CREATE TABLE emotes (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,user_id bigint NOT NULL,code text COLLATE "C" NOT NULL,url text COLLATE "C" NOT NULL,@@@ -283,7 +283,7 @@ CREATE INDEX idx_emotes_channel_owner ON emotes(channel_owner_id);CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code);CREATE TABLE user_tags (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,user_id bigint NOT NULL,tag_id text COLLATE "C" NOT NULL,source text COLLATE "C" DEFAULT 'shop',@@@ -293,7 +293,7 @@ CREATE TABLE user_tags (CREATE INDEX idx_user_tags_user ON user_tags(user_id);CREATE TABLE chat_ai_summaries (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,scope text COLLATE "C" NOT NULL,subject_id bigint NOT NULL DEFAULT 0,"window" text COLLATE "C" NOT NULL,@@@ -311,7 +311,7 @@ CREATE TABLE chat_ai_summaries ();CREATE TABLE chat_timeline_events (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,scope text COLLATE "C" NOT NULL DEFAULT 'global',subject_id bigint NOT NULL DEFAULT 0,ts text COLLATE "C" NOT NULL,@@@ -385,7 +385,7 @@ CREATE TABLE ctx_sync (-- Transactional outbox (events.event-envelope@1), written in the same transaction as the change.CREATE TABLE events_outbox (-- seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ seq bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,event_id text COLLATE "C" NOT NULL UNIQUE,event_type text COLLATE "C" NOT NULL,event text COLLATE "C" NOT NULL,@@@ -399,7 +399,7 @@ CREATE INDEX idx_events_outbox_unsent ON events_outbox(sent_at, seq);-- Row changes to mirror into Live's copy of Chat's tables (filled by per-connection TEMP-- triggers, so only writes made by this service are captured — never the importer's).CREATE TABLE live_mirror_outbox (-- seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ seq bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,tbl text COLLATE "C" NOT NULL,op text COLLATE "C" NOT NULL CHECK(op IN ('upsert', 'delete')),pk text COLLATE "C" NOT NULL,@@@ -419,7 +419,7 @@ CREATE TABLE bridge_applied (-- queued → playing → played, or skipped (moderators, the broadcaster) / failed. payload holds what-- is needed to make the audio when its turn comes, so the queue survives a restart. Times are ms.CREATE TABLE audio_requests (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,room text COLLATE "C" NOT NULL,stream_id bigint,channel_user_id bigint,@@@ -455,7 +455,7 @@ CREATE TABLE bridge_refs (-- Rows the importer could not represent. Never dropped; reviewed by hand.CREATE TABLE import_hold (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,source_table text COLLATE "C" NOT NULL,source_pk text COLLATE "C" NOT NULL,reason text COLLATE "C" NOT NULL,@@@ -465,7 +465,7 @@ CREATE TABLE import_hold ();CREATE TABLE import_runs (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,live_db text COLLATE "C",dry_run bigint DEFAULT 0,counts text COLLATE "C",@@@ -511,7 +511,7 @@ CREATE TABLE chat_event_inbox (-- a session of a voice channel (a stream-linked one for 'stream'): active from the first person in-- until the channel empties or is closed → ended. Times are ms.CREATE TABLE calls (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,kind text COLLATE "C" NOT NULL CHECK(kind IN ('channel', 'direct', 'stream')),state text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'ringing', 'active', 'ended', 'missed', 'declined', 'failed')),channel_id text COLLATE "C" NOT NULL,@@@ -531,7 +531,7 @@ CREATE INDEX idx_calls_open ON calls(state) WHERE state IN ('pending', 'ringing'CREATE TABLE rooms (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,slug text COLLATE "C" UNIQUE NOT NULL,name text COLLATE "C" NOT NULL,topic text COLLATE "C",@@@ -557,7 +557,7 @@ CREATE TABLE room_members ();CREATE INDEX idx_room_members_user ON room_members(user_id);CREATE TABLE room_messages (-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,room_id bigint NOT NULL,user_id bigint NOT NULL,subject_id text COLLATE "C",@@@ -9,7 +9,6 @@"version": "0.1.0","license": "AGPL-3.0-only","dependencies": {-- "better-sqlite3": "^11.10.0","cookie-parser": "^1.4.7","cors": "^2.8.5","dotenv": "^16.4.7",@@@ -24,7 +23,8 @@"ws": "^8.18.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "@electric-sql/pglite-socket": "0.2.11"},"engines": {"node": ">=22.0.0"@@@ -37,6 +37,103 @@"devOptional": true,"license": "Apache-2.0"},++ "node_modules/@electric-sql/pglite-age": {++ "version": "0.0.9",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-age/-/pglite-age-0.0.9.tgz",++ "integrity": "sha512-IdNy5P5nxwKVeSPLjDLXkp/eYffH5roympM7fTG+g9ehzBrDnQ9TT2tfCJ2vRVcOSPQBp4oURzfRE19cs/x8ZA==",++ "dev": true,++ "license": "Apache-2.0",++ "peer": true,++ "peerDependencies": {++ "@electric-sql/pglite": "0.5.8"++ }++ },++ "node_modules/@electric-sql/pglite-pg_hashids": {++ "version": "0.0.9",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_hashids/-/pglite-pg_hashids-0.0.9.tgz",++ "integrity": "sha512-NffsZH+FaRLp0WzsYG35BW/wOHLsTs325zjgp+PnKlWeGyW523Mbw4EZ1Awm02C3QWBU9p8Y2Fk02Al5NpRGUg==",++ "dev": true,++ "license": "Apache-2.0",++ "peer": true,++ "peerDependencies": {++ "@electric-sql/pglite": "0.5.8"++ }++ },++ "node_modules/@electric-sql/pglite-pg_ivm": {++ "version": "0.0.9",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_ivm/-/pglite-pg_ivm-0.0.9.tgz",++ "integrity": "sha512-mbTcDLQyzeOG4rX6LLHhWDPJeQUv2xHvayKtf4oE7xujWEGWEDUEJP1+U0pXXELtIoWiUzzPBWjzKY1mRisqkQ==",++ "dev": true,++ "license": "Apache-2.0",++ "peer": true,++ "peerDependencies": {++ "@electric-sql/pglite": "0.5.8"++ }++ },++ "node_modules/@electric-sql/pglite-pg_textsearch": {++ "version": "0.0.10",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_textsearch/-/pglite-pg_textsearch-0.0.10.tgz",++ "integrity": "sha512-tsL1rTYU4tzIC44htL7/0nS6C2XC5Srz0tfxU1LJelCqNIBkpFI9Esw1JbuVCaQKUHGDbPgAz8JYavNxwbuRVw==",++ "dev": true,++ "license": "Apache-2.0",++ "peer": true,++ "peerDependencies": {++ "@electric-sql/pglite": "0.5.8"++ }++ },++ "node_modules/@electric-sql/pglite-pg_uuidv7": {++ "version": "0.0.9",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_uuidv7/-/pglite-pg_uuidv7-0.0.9.tgz",++ "integrity": "sha512-n/JbzoQMF9jFKFxGLRIzUfC1O4V2vTE0Pt3uoAO6/EP7dX5m1h4zBdezQAcT2JFostD0YWUdzHj9iYvcbUGM1A==",++ "dev": true,++ "license": "Apache-2.0",++ "peer": true,++ "peerDependencies": {++ "@electric-sql/pglite": "0.5.8"++ }++ },++ "node_modules/@electric-sql/pglite-pgtap": {++ "version": "0.0.9",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pgtap/-/pglite-pgtap-0.0.9.tgz",++ "integrity": "sha512-VIyclfOawUN0IJROKiCnrVxsZQEG7tixgiC8c8nxp9TlBhI79MZqotoqlqvdPzGHmjWZlcgJ7tC8InOcy+2A/A==",++ "dev": true,++ "license": "Apache-2.0",++ "peer": true,++ "peerDependencies": {++ "@electric-sql/pglite": "0.5.8"++ }++ },++ "node_modules/@electric-sql/pglite-pgvector": {++ "version": "0.0.9",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pgvector/-/pglite-pgvector-0.0.9.tgz",++ "integrity": "sha512-ue4iBW651gDQwBwn97Ekv1lYGPvXa1ymHbRbTCSL0Ib286PRDD1VDOwzwEoekZuO/wctMbTzKzlwdgDwYrqZ8A==",++ "dev": true,++ "license": "Apache-2.0",++ "peer": true,++ "peerDependencies": {++ "@electric-sql/pglite": "0.5.8"++ }++ },++ "node_modules/@electric-sql/pglite-socket": {++ "version": "0.2.11",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.2.11.tgz",++ "integrity": "sha512-DKnJBf7+5zFCXXGZAGUvGoWKetPPvaQ7fLSGVqP5onzgBhV9vvTcHigireMROMglgovvhoXHaMOKqt69/vts4A==",++ "dev": true,++ "license": "Apache-2.0",++ "bin": {++ "pglite-server": "dist/scripts/server.js"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "0.5.8",++ "@electric-sql/pglite-age": "0.0.9",++ "@electric-sql/pglite-pg_hashids": "0.0.9",++ "@electric-sql/pglite-pg_ivm": "0.0.9",++ "@electric-sql/pglite-pg_textsearch": "0.0.10",++ "@electric-sql/pglite-pg_uuidv7": "0.0.9",++ "@electric-sql/pglite-pgtap": "0.0.9",++ "@electric-sql/pglite-pgvector": "0.0.9"++ }++ },"node_modules/@iovalkey/commands": {"version": "0.1.0","resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",@@@ -119,7 +216,9 @@"url": "https://feross.org/support"}],-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/better-sqlite3": {"version": "11.10.0",@@@ -127,6 +226,8 @@"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==","hasInstallScript": true,"license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"bindings": "^1.5.0","prebuild-install": "^7.1.1"@@@ -137,6 +238,8 @@"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz","integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"file-uri-to-path": "1.0.0"}@@@ -146,6 +249,8 @@"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz","integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"buffer": "^5.5.0","inherits": "^2.0.4",@@@ -195,6 +300,8 @@}],"license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"base64-js": "^1.3.1","ieee754": "^1.1.13"@@@ -259,7 +366,9 @@"version": "1.1.4","resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz","integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",-- "license": "ISC"++ "license": "ISC",++ "optional": true,++ "peer": true},"node_modules/cluster-key-slot": {"version": "1.1.2",@@@ -350,6 +459,8 @@"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz","integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"mimic-response": "^3.1.0"},@@@ -365,6 +476,8 @@"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz","integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==","license": "MIT",++ "optional": true,++ "peer": true,"engines": {"node": ">=4.0.0"}@@@ -402,6 +515,8 @@"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz","integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==","license": "Apache-2.0",++ "optional": true,++ "peer": true,"engines": {"node": ">=8"}@@@ -461,6 +576,8 @@"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz","integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"once": "^1.4.0"}@@@ -515,6 +632,8 @@"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz","integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==","license": "(MIT OR WTFPL)",++ "optional": true,++ "peer": true,"engines": {"node": ">=6"}@@@ -606,7 +725,9 @@"version": "1.0.0","resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz","integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/finalhandler": {"version": "1.3.2",@@@ -648,7 +769,9 @@"version": "1.0.0","resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz","integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/function-bind": {"version": "1.1.2",@@@ -700,7 +823,9 @@"version": "0.0.0","resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz","integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/gopd": {"version": "1.2.0",@@@ -788,7 +913,9 @@"url": "https://feross.org/support"}],-- "license": "BSD-3-Clause"++ "license": "BSD-3-Clause",++ "optional": true,++ "peer": true},"node_modules/inherits": {"version": "2.0.4",@@@ -800,7 +927,9 @@"version": "1.3.8","resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz","integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",-- "license": "ISC"++ "license": "ISC",++ "optional": true,++ "peer": true},"node_modules/iovalkey": {"version": "0.4.0",@@@ -1037,6 +1166,8 @@"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz","integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==","license": "MIT",++ "optional": true,++ "peer": true,"engines": {"node": ">=10"},@@@ -1049,6 +1180,8 @@"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz","integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==","license": "MIT",++ "optional": true,++ "peer": true,"funding": {"url": "https://github.com/sponsors/ljharb"}@@@ -1057,7 +1190,9 @@"version": "0.5.3","resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz","integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/ms": {"version": "2.0.0",@@@ -1087,7 +1222,9 @@"version": "2.0.0","resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz","integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/negotiator": {"version": "0.6.3",@@@ -1103,6 +1240,8 @@"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz","integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"semver": "^7.3.5"},@@@ -1148,6 +1287,8 @@"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz","integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==","license": "ISC",++ "optional": true,++ "peer": true,"dependencies": {"wrappy": "1"}@@@ -1354,6 +1495,8 @@"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==","deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"detect-libc": "^2.0.0","expand-template": "^2.0.3",@@@ -1397,6 +1540,8 @@"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz","integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"end-of-stream": "^1.1.0","once": "^1.3.1"@@@ -1447,6 +1592,8 @@"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz","integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==","license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "optional": true,++ "peer": true,"dependencies": {"deep-extend": "^0.6.0","ini": "~1.3.0",@@@ -1462,6 +1609,8 @@"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz","integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"inherits": "^2.0.3","string_decoder": "^1.1.1",@@@ -1680,7 +1829,9 @@"url": "https://feross.org/support"}],-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/simple-get": {"version": "4.0.1",@@@ -1701,6 +1852,8 @@}],"license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"decompress-response": "^6.0.0","once": "^1.3.1",@@@ -1744,6 +1897,8 @@"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz","integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"safe-buffer": "~5.2.0"}@@@ -1753,6 +1908,8 @@"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz","integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==","license": "MIT",++ "optional": true,++ "peer": true,"engines": {"node": ">=0.10.0"}@@@ -1762,6 +1919,8 @@"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz","integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"chownr": "^1.1.1","mkdirp-classic": "^0.5.2",@@@ -1774,6 +1933,8 @@"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz","integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==","license": "MIT",++ "optional": true,++ "peer": true,"dependencies": {"bl": "^4.0.3","end-of-stream": "^1.4.1",@@@ -1799,6 +1960,8 @@"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz","integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==","license": "Apache-2.0",++ "optional": true,++ "peer": true,"dependencies": {"safe-buffer": "^5.0.1"},@@@ -1832,7 +1995,9 @@"version": "1.0.2","resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz","integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",-- "license": "MIT"++ "license": "MIT",++ "optional": true,++ "peer": true},"node_modules/utils-merge": {"version": "1.0.1",@@@ -1856,7 +2021,9 @@"version": "1.0.2","resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",-- "license": "ISC"++ "license": "ISC",++ "optional": true,++ "peer": true},"node_modules/ws": {"version": "8.21.3",@@@ -22,7 +22,6 @@"author": "OpenVibers","license": "AGPL-3.0-only","dependencies": {-- "better-sqlite3": "^11.10.0","cookie-parser": "^1.4.7","cors": "^2.8.5","dotenv": "^16.4.7",@@@ -37,7 +36,8 @@"ws": "^8.18.0"},"devDependencies": {-- "@electric-sql/pglite": "^0.5.8"++ "@electric-sql/pglite": "^0.5.8",++ "@electric-sql/pglite-socket": "0.2.11"},"engines": {"node": ">=22.0.0"@@@ -3,19 +3,24 @@* 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]]-- * [--chat-db <path>] [--headroom <n>] [--tables a,b] [--no-projections]++ * [--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 Chat's database (VACUUM INTO <chat db>.pre-import-<time>, or --backup <path>) and-- * says where in the report.++ * 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, so rows Live writes between the++ * `--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.@@@ -33,15 +38,15 @@*/'use strict';++const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');++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 === '--chat-db') out.chatDb = 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];@@@ -82,95 +87,107 @@ const SUBJECT_COLUMNS = {};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 {}--/** Copy Chat's database before a write run (VACUUM INTO: consistent, compact, the WAL included). */--function backupChat(chat, target) {-- const fs = require('fs');-- const file = path.resolve(target || `${chat.name}.pre-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);++// 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`);-- chat.prepare('VACUUM INTO ?').run(file);++ 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;}--function run(opts) {++async function run(opts) {if (!opts.liveDb) throw new Error('--live-db <snapshot> is required');-- if (opts.chatDb) process.env.CHAT_DB_PATH = opts.chatDb;const db = require('../server/db/database');-- // No mirror capture: these rows came from Live.-- db.initDb({ captureMirror: false });++ await db.initDb();const chat = db.getDb();-- const live = new Database(path.resolve(opts.liveDb), { readonly: true, fileMustExist: true });++ 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 colsOf = (conn, t) => conn.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.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 })),++ ...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.++ // 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(colsOf(chat, p.table));-- const extra = colsOf(live, p.table).filter((c) => !chatCols.has(c) && !(LIVE_ONLY_COLUMNS[p.table] || []).includes(c));++ 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 — update server/db/schema.sql first:\n ${drift.join('\n ')}`);++ 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') && colsOf(live, 'linked_accounts').includes('subject_id')) {++ 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 OR IGNORE INTO import_hold (source_table, source_pk, reason, row_json) VALUES (?, ?, ?, ?)');-- const hold = (table, pkObj, reason, row) => holdStmt.run(table, JSON.stringify(pkObj), String(reason).slice(0, 300), JSON.stringify(row)).changes;++ 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 = backupChat(chat, opts.backupPath);++ 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(colsOf(chat, p.table));-- const cols = colsOf(live, p.table).filter((c) => chatCols.has(c));++ 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]) => sc));-- const where = p.pk.map((k) => `${k} = ?`).join(' AND ');-- const getChat = chat.prepare(`SELECT * FROM ${p.table} WHERE ${where}`);-- const ins = chat.prepare(`INSERT INTO ${p.table} (${insertCols.join(', ')}) VALUES (${insertCols.map(() => '?').join(', ')})`);++ const 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.join(', ');++ const orderBy = p.pk.map(ident).join(', ');-- const apply = () => {-- for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${p.table} ORDER BY ${orderBy}`).iterate()) {++ 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 = getChat.get(...pkVals);++ 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 {-- ins.run(...cols.map((c) => row[c]), ...subjectCols.map(([, uidCol]) => subjects.get(row[uidCol]) || null));++ await chat.tx(() => ins.run(...cols.map((c) => row[c]), ...subjectCols.map(([, uidCol]) => subjects.get(row[uidCol]) || null)));r.inserted++;} catch (err) {-- r.held += hold(p.table, pkObj, `insert failed: ${err.message}`, row);++ r.held += await hold(p.table, pkObj, `insert failed: ${err.message}`, row);}continue;}-- const same = cols.every((c) => existing[c] === row[c] || (existing[c] == null && row[c] == null));-- if (same) { r.identical++; continue; }-- if (identity && !identity.every((c) => !cols.includes(c) || existing[c] === row[c] || (existing[c] == null && row[c] == null))) {++ 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 += hold(p.table, pkObj, 'id already used by a different row in chat', row);++ r.held += await hold(p.table, pkObj, 'id already used by a different row in chat', row);continue;}r.chat_kept++;@@@ -178,7 +195,7 @@ function run(opts) {};try {-- chat.transaction(() => { apply(); if (opts.dryRun) throw new Rollback(); })();++ await chat.tx(async () => { await noMirror(); await apply(); if (opts.dryRun) throw new Rollback(); });} catch (err) {if (!(err instanceof Rollback)) throw err;}@@@ -191,81 +208,88 @@ function run(opts) {const r = { live: 0, held: 0 };report.tables[t] = r;try {-- chat.transaction(() => {-- const pkCol = colsOf(live, t).includes('id') ? 'id' : 'rowid';-- for (const row of live.prepare(`SELECT rowid AS _rowid, * FROM ${t}`).iterate()) {++ 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 += hold(t, { [pkCol]: pkCol === 'id' ? row.id : row._rowid }, 'row of a transient migration table (Live rebuilds tables through <name>_new)', row);++ 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 ${t}`).get().n, not_moved: 'stays in Live (media queue is Live-owned in W6)' };++ 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).-- chat.transaction(() => {++ // 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) || !colsOf(live, t).includes('id')) continue;-- const liveMax = live.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${t}`).get().m;-- const chatMax = chat.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${t}`).get().m;-- const target = Math.max(liveMax + opts.headroom, chatMax);-- const cur = chat.prepare('SELECT seq FROM sqlite_sequence WHERE name = ?').get(t);-- if (!cur) chat.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(t, target);-- else if (cur.seq < target) chat.prepare('UPDATE sqlite_sequence SET seq = ? WHERE name = ?').run(target, t);-- report.tables[t].next_id = Math.max(target, cur ? cur.seq : 0) + 1;++ 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 = (table, sql, cols) => {++ const seed = async (table, sql, cols) => {if (!liveTables.has(sql.table)) return 0;-- const have = new Set(colsOf(live, sql.table));++ const have = new Set(colsOfLive(sql.table));const pick = cols.filter((c) => have.has(c.from || c.to));-- const st = chat.prepare(`INSERT INTO ${table} (${pick.map((c) => c.to).join(', ')}, synced_at) VALUES (${pick.map(() => '?').join(', ')}, 0)++ 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;-- chat.transaction(() => {-- for (const row of live.prepare(`SELECT ${pick.map((c) => c.from || c.to).join(', ')} FROM ${sql.table}`).iterate()) {-- n += st.run(...pick.map((c) => (c.to === 'subject_id' ? (subjects.get(row.id) || null) : row[c.from || c.to]))).changes;++ 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: 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: 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: seed('ctx_managed_streams', { table: 'managed_streams' }, [c('id'), c('user_id'), c('slug'), c('title'), c('sort_order'), c('created_at')]),-- ctx_channels: seed('ctx_channels', { table: 'channels' }, [c('id'), c('user_id'), c('title')]),++ 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')]),};}-- chat.prepare('INSERT INTO import_runs (live_db, dry_run, counts, finished_at) VALUES (?, 0, ?, CURRENT_TIMESTAMP)').run(report.live_db, JSON.stringify(report.tables));++ 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 = chat.prepare('SELECT COUNT(*) AS n FROM import_hold').get().n;++ report.held_total = (await chat.prepare('SELECT COUNT(*) AS n FROM import_hold').get()).n;live.close();return report;}--if (require.main === module) {++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(process.argv.slice(2)); } catch (err) { console.error(err.message); process.exit(2); }-- if (opts.help || !opts.liveDb) {-- console.log('usage: node scripts/import-from-live.js --live-db <snapshot> [--apply [--backup <path> | --no-backup]] [--chat-db <path>] [--headroom <n>] [--tables a,b] [--no-projections]');-- process.exit(opts.help ? 0 : 2);-- }++ 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 = run(opts);-- console.log(JSON.stringify(report, null, 2));-- try { require('../server/db/database').close(); } catch { /* */ }++ const report = await run(opts);++ return { code: 0, stdout: `${JSON.stringify(report, null, 2)}\n`, stderr: '' };} catch (err) {-- console.error(err.message);-- process.exit(err.code === 'SCHEMA_DRIFT' ? 1 : 3);++ return { code: err.code === 'SCHEMA_DRIFT' ? 1 : 3, stdout: '', stderr: `${err.message}\n` };}}--module.exports = { run, parseArgs, IDENTITY, SUBJECT_COLUMNS, LIVE_ONLY_COLUMNS };++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 };@@@ -16,6 +16,7 @@const path = require('path');const { createDb, importSqlite } = require('openvibe-sdk/db');++const { openSqlite } = require('./lib/sqlite');const MIGRATIONS = path.join(__dirname, '..', 'migrations');@@@ -45,12 +46,16 @@ async function main(argv) {? createDb({ pglite: args.pglite, service: 'chat-import', max: 1 }): createDb({ url: args.url, service: 'chat-import', max: 1 });try {-- if (args.pglite) await db.migrate({ dir: MIGRATIONS });++ // 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);-- const report = await importSqlite({ sqlite: args.sqlite, db, truncate: args.truncate, verify: args.verify });++ // 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 untilnnew file mode 100644@@@ -0,0 +1,56 @@++'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 };@@@ -70,10 +70,10 @@ async function main() {console.log(JSON.stringify(report, null, 2));return;}-- const Database = require('better-sqlite3');-- const liveDb = new Database(args.liveDb, { readonly: true, fileMustExist: true });++ const { openSqlite } = require('./lib/sqlite');++ const liveDb = openSqlite(args.liveDb);let planned;-- try { planned = migration.plan(liveDb); } finally { liveDb.close(); }++ 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)');@@@ -13,7 +13,7 @@* Options:* --out <file> required with --apply; must not exist* --resolve first ask Live (live-context, as Chat does at runtime) for people missing from ctx_users-- * --db <path> Chat's database (default CHAT_DB_PATH, else ./data/chat.db)++ * Chat's database is the service's (DATABASE_URL / DATABASE_DIRECT_URL, or the development PGlite).** People are Live ids here; each is mapped to its Network subject the way Chat records it: the blocker's* dm_blocks.blocker_subject_id (written when they blocked), else ctx_users.subject_id; the blocked person's@@@ -28,9 +28,8 @@'use strict';const fs = require('fs');--const path = require('path');--const USAGE = 'usage: node scripts/migrate-dm-blocks-to-network.js [--db <chat.db>] [--resolve] [--apply --out <file>]';++const USAGE = 'usage: node scripts/migrate-dm-blocks-to-network.js [--resolve] [--apply --out <file>]';const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;function parseArgs(argv) {@@@ -42,7 +41,6 @@ function parseArgs(argv) {else if (a === '--dry-run') out.apply = false;else if (a === '--resolve') out.resolve = true;else if (a === '--out') out.out = value();-- else if (a === '--db') out.db = value();else if (a === '-h' || a === '--help') out.help = true;else throw new Error(`unknown argument ${a}`);}@@@ -52,8 +50,8 @@ function parseArgs(argv) {}/** dm_blocks → { pairs: [{ blocker_subject, blocked_subject }], rows, skipped: { reason: n }, unmapped_ids } */--function plan(db) {-- const rows = db.all(`SELECT b.id, b.blocker_id, b.blocked_id, b.blocker_subject_id, a.subject_id AS blocker_ctx, c.subject_id AS blocked_ctx++async function plan(db) {++ const rows = await db.all(`SELECT b.id, b.blocker_id, b.blocked_id, b.blocker_subject_id, a.subject_id AS blocker_ctx, c.subject_id AS blocked_ctxFROM dm_blocks b LEFT JOIN ctx_users a ON a.id = b.blocker_id LEFT JOIN ctx_users c ON c.id = b.blocked_id ORDER BY b.id`);const ok = (s) => (SUBJECT_RE.test(String(s || '')) ? String(s) : null);const pairs = [];@@@ -79,21 +77,18 @@ async function main(argv, log = (m) => console.log(m)) {let args;try { args = parseArgs(argv); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }if (args.help) { log(USAGE); return 0; }-- if (args.db) {-- if (!fs.existsSync(args.db)) { log(`no Chat database at ${args.db}`); return 2; }-- process.env.CHAT_DB_PATH = path.resolve(args.db);-- }const db = require('../server/db/database');++ await db.initDb();if (args.resolve) {-- const missing = db.all(`SELECT DISTINCT id FROM (SELECT blocker_id AS id FROM dm_blocks UNION SELECT blocked_id FROM dm_blocks)-- WHERE id NOT IN (SELECT id FROM ctx_users)`).map((r) => r.id);++ const missing = (await db.all(`SELECT DISTINCT id FROM (SELECT blocker_id AS id FROM dm_blocks UNION SELECT blocked_id FROM dm_blocks) AS people++ WHERE id NOT IN (SELECT id FROM ctx_users) ORDER BY id`)).map((r) => r.id);if (missing.length) {const ctx = require('../server/live-context');for (let i = 0; i < missing.length; i += 500) await ctx.ensureUsers(missing.slice(i, i + 500));}log(`asked Live for ${missing.length} person(s) missing from ctx_users`);}-- const p = plan(db);++ const p = await plan(db);const why = Object.entries(p.skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';log(`dm_blocks ${p.rows}; pairs for Network ${p.pairs.length}; skipped: ${why}`);if (p.unmapped_ids.length) log(`Live ids without a Network subject: ${p.unmapped_ids.slice(0, 50).join(', ')}${p.unmapped_ids.length > 50 ? ` (+${p.unmapped_ids.length - 50} more)` : ''}`);@@@ -3,7 +3,7 @@* Send whatever is still queued in live_mirror_outbox to Live, once, without starting the service.* For a rollback when the service cannot run: every change Chat made reaches Live's tables before* Live becomes the chat authority again (docs/cutover.md). Uses the same env as the service-- * (/etc/openvibe/chat.env); LIVE_MIRROR is forced on.++ * (/etc/openvibe/chat.env: DATABASE_URL, DATABASE_DIRECT_URL); LIVE_MIRROR is forced on.** node scripts/mirror-flush.js → { sent, pending, error }*/@@@ -15,11 +15,11 @@ const db = require('../server/db/database');const { createMirror } = require('../server/bridge/live-mirror');(async () => {-- db.initDb({ captureMirror: false });++ await db.initDb();const mirror = createMirror({ config });-- const before = mirror.pending();++ const before = await mirror.pending();const r = await mirror.flush();console.log(JSON.stringify({ pending_before: before, ...r }, null, 2));-- db.close();++ await db.close();process.exit(r.pending ? 1 : 0);})().catch((err) => { console.error(err.message); process.exit(3); });@@@ -12,13 +12,18 @@* the way test/n-1/service.js says, and every call its client code makes (test/n-1/harness.js finds* 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).*/const fs = require('fs');const os = require('os');const path = require('path');--const Database = require('better-sqlite3');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');@@@ -27,14 +32,15 @@ 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 dbPath = path.join(tmp, 'db', `${svc.service}.db`);++ const pg = onPostgres(wt.dir);++ const dbPath = pg ? path.join(tmp, 'db', 'pglite') : path.join(tmp, 'db', `${svc.service}.db`);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})`);++ console.log(`n-1:record: ${svc.service} at ${wt.sha.slice(0, 12)} (${ref}), on ${pg ? 'PostgreSQL' : 'SQLite'}`);// 1. The client: its calls, and the names it reads.const files = svc.clientFiles(wt.dir);@@@ -99,6 +105,33 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');server = null;// 3. The schema N-1 left, and the SQL it runs on it.++ 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 {++ const migrations = await h.pgMigrations(db, path.join(wt.dir, 'migrations'));++ const ledger = {};++ for (const t of svc.ledgerTables || []) {++ try { ledger[t] = await db.many(`SELECT * FROM "${t}"`); } catch { /* not there */ }++ }++ const ran = fs.existsSync(sqlOut) ? JSON.parse(fs.readFileSync(sqlOut, 'utf8')) : [];++ // SQL literals in the source compile to what PostgreSQL receives (? and @name → $n), like the ones that ran.++ const literals = h.sqlLiterals(h.readTree(wt.dir, svc.sqlDirs)).map((s) => { try { return db.prepare(s).source; } catch { return null; } }).filter(Boolean);++ const candidates = [...new Set([...ran, ...literals].map(h.normalizeSql).filter((s) => /^\s*(SELECT|INSERT|UPDATE|DELETE|WITH)\b/i.test(s)))].sort();++ const failing = new Set((await h.pgPrepareProblems(db, candidates)).map((p) => p.sql));++ const statements = candidates.filter((s) => !failing.has(s));++ console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${migrations.length} migrations`);++ fs.mkdirSync(OUT, { recursive: true });++ writeClient();++ fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, engine: 'postgresql', migrations, ledger, statements }, null, 1)}\n`);++ console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);++ } finally { await db.close().catch(() => {}); }++ return;++ }++ const Database = require('better-sqlite3');const db = new Database(dbPath);const schema = h.schemaDDL(db);const ledger = {};@@@ -117,9 +150,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');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 });-- 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;-- fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);++ 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 {@@@ -614,7 +614,7 @@ function openSocket(url, headers) {};ws.none = async (pred, ms = QUIET_MS) => { await new Promise((r) => setTimeout(r, ms)); return !all.some(pred); };ws.sendJson = (o) => ws.send(JSON.stringify(o));-- ws.once('open', () => resolve(ws));++ ws.once('open', async () => await resolve(ws));ws.once('error', reject);ws.once('unexpected-response', (req, res) => reject(new Error(`upgrade refused ${res.statusCode}`)));});@@@ -58,7 +58,7 @@ let _tickInFlight = false;let _lastUserPass = 0;// ── Small utils ──────────────────────────────────────────────────────────────--// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (CURRENT_TIMESTAMP). Match that format.++// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (ov_now()). Match that format.function _sqlTime(d) { return new Date(d).toISOString().slice(0, 19).replace('T', ' '); }function _parseSqlTime(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }function _clip(str, n) { str = (str == null ? '' : String(str)).trim(); return str.length > n ? str.slice(0, n) : str; }@@@ -120,10 +120,10 @@ function _cleanTimeline(arr) {// ── GLOBAL ───────────────────────────────────────────────────────────────────async function _refreshGlobal() {-- const prior = db.getChatAiSummary('global', 0, 'global');++ const prior = await db.getChatAiSummary('global', 0, 'global');const hw = prior ? (prior.last_message_id || 0) : 0;-- const maxId = db.getMaxChatMessageId();-- const newCount = maxId > hw ? db.countChatMessagesSince(hw) : 0;++ const maxId = await db.getMaxChatMessageId();++ const newCount = maxId > hw ? await db.countChatMessagesSince(hw) : 0;if (newCount === 0) return false;const ageMs = prior && prior.updated_at ? (Date.now() - _parseSqlTime(prior.updated_at)) : Infinity;@@@ -131,14 +131,14 @@ async function _refreshGlobal() {if (newCount < GLOBAL_MSG_THRESHOLD && ageMs < GLOBAL_MAX_AGE_MS) return false;const now = Date.now();-- const nthTs = db.getNthRecentChatTs(WINDOW_TARGET_MESSAGES);++ const nthTs = await db.getNthRecentChatTs(WINDOW_TARGET_MESSAGES);let startMs = nthTs ? _parseSqlTime(nthTs) : (now - WINDOW_MAX_MS);startMs = Math.min(startMs, now - WINDOW_MIN_MS);startMs = Math.max(startMs, now - WINDOW_MAX_MS);const windowStart = _sqlTime(startMs);const windowLabel = _windowLabel(now - startMs);-- const rows = db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });++ const rows = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });if (!rows.length) return false;const priorMemory = prior ? (prior.memory_json || '') : '';let priorTl = [];@@@ -153,8 +153,8 @@ async function _refreshGlobal() {if (!parsed) return false;const nowIso = _sqlTime(now);-- db.addChatTimelineEvents('global', 0, _stampAdditions(parsed.timeline, nowIso, now));-- db.upsertChatAiSummary({++ await db.addChatTimelineEvents('global', 0, _stampAdditions(parsed.timeline, nowIso, now));++ await db.upsertChatAiSummary({scope: 'global', subject_id: 0, window: 'global',overview: _clip(parsed.recent_overview || '', 2000),memory_json: _clip(parsed.memory || priorMemory, MEMORY_MAX_CHARS),@@@ -172,13 +172,13 @@ async function _refreshGlobal() {// ── PER-USER ─────────────────────────────────────────────────────────────────async function _refreshUser(uid, maxId) {-- const prior = db.getChatAiSummary('user', uid, 'rolling');++ const prior = await db.getChatAiSummary('user', uid, 'rolling');const now = Date.now();const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);-- let dayRows = db.getChatMessagesForAi({ sinceTs: dayStart, userId: uid, order: 'asc', limit: MAX_BATCH_MESSAGES });++ let dayRows = await db.getChatMessagesForAi({ sinceTs: dayStart, userId: uid, order: 'asc', limit: MAX_BATCH_MESSAGES });let has24h = dayRows.length > 0;-- if (!dayRows.length) dayRows = db.getChatMessagesForAi({ userId: uid, order: 'desc', limit: 80 });++ if (!dayRows.length) dayRows = await db.getChatMessagesForAi({ userId: uid, order: 'desc', limit: 80 });if (!dayRows.length) return false;const uname = dayRows[dayRows.length - 1].username || `user#${uid}`;@@@ -192,11 +192,11 @@ async function _refreshUser(uid, maxId) {if (!parsed) parsed = extractive.profileFrom(dayRows, { name: uname, subjectKind: 'user', priorMemory, has24h, seen: totalSeen, now });if (!parsed) return false;-- const newCount = db.countChatMessagesSince(prior ? (prior.last_message_id || 0) : 0, uid);++ const newCount = await db.countChatMessagesSince(prior ? (prior.last_message_id || 0) : 0, uid);const nowIso = _sqlTime(now);const _lastRow = dayRows[dayRows.length - 1] || {};const activityTs = _lastRow.timestamp || _lastRow.created_at || nowIso;-- db.upsertChatAiSummary({++ await db.upsertChatAiSummary({scope: 'user', subject_id: uid, window: 'rolling',overview: JSON.stringify({today: _clip(parsed.overview_24h || '', 1200),@@@ -207,7 +207,7 @@ async function _refreshUser(uid, maxId) {timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, activityTs, now),message_count: totalSeen + newCount,window_message_count: dayRows.length,-- last_message_id: maxId || db.getMaxChatMessageId(),++ last_message_id: maxId || await db.getMaxChatMessageId(),window_label: has24h ? 'past 24h' : 'recent',window_start: has24h ? dayStart : null,window_end: nowIso,@@@ -221,7 +221,7 @@ async function _userPass() {const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);let candidates = [];try {-- candidates = db.getUsersNeedingChatAi({ threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK });++ candidates = await db.getUsersNeedingChatAi({ threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK });} catch (e) { console.warn('[ChatAI] user discovery failed:', e.message); return; }for (const c of candidates) {if (!c.uid) continue;@@@ -232,13 +232,13 @@ async function _userPass() {// ── PER RELAY-USER (external platform chatters bridged in) ────────────────────async function _refreshRelayUser(ru) {-- const prior = db.getChatAiSummary('relay', ru.id, 'rolling');++ const prior = await db.getChatAiSummary('relay', ru.id, 'rolling');const now = Date.now();const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);-- let dayRows = db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });++ let dayRows = await db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });let has24h = dayRows.length > 0;-- if (!dayRows.length) dayRows = db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, order: 'desc', limit: 80 });++ if (!dayRows.length) dayRows = await db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, order: 'desc', limit: 80 });if (!dayRows.length) return false;const uname = ru.display_name || ru.username;@@@ -252,7 +252,7 @@ async function _refreshRelayUser(ru) {if (!parsed) return false;const nowIso = _sqlTime(now);-- db.upsertChatAiSummary({++ await db.upsertChatAiSummary({scope: 'relay', subject_id: ru.id, window: 'rolling',overview: JSON.stringify({today: _clip(parsed.overview_24h || '', 1200),@@@ -263,7 +263,7 @@ async function _refreshRelayUser(ru) {timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),message_count: ru.message_count || 0,window_message_count: dayRows.length,-- last_message_id: db.getMaxChatMessageId(),++ last_message_id: await db.getMaxChatMessageId(),window_label: has24h ? 'past 24h' : 'recent',window_start: has24h ? dayStart : null,window_end: nowIso,@@@ -275,7 +275,7 @@ async function _refreshRelayUser(ru) {async function _relayPass() {const lookbackIso = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);let candidates = [];-- try { candidates = db.getRelayUsersNeedingChatAi({ lookbackIso, threshold: 8, limit: USER_MAX_PER_TICK }); }++ try { candidates = await db.getRelayUsersNeedingChatAi({ lookbackIso, threshold: 8, limit: USER_MAX_PER_TICK }); }catch (e) { console.warn('[ChatAI] relay discovery failed:', e.message); return; }for (const ru of candidates) {if (!ru.id) continue;@@@ -288,13 +288,13 @@ async function _relayPass() {async function _refreshAnon(anonId) {const subjectId = db.anonSubjectId(anonId);if (!subjectId) return false;-- const prior = db.getChatAiSummary('anon', subjectId, 'rolling');++ const prior = await db.getChatAiSummary('anon', subjectId, 'rolling');const now = Date.now();const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);-- let dayRows = db.getAnonChatMessagesForAi({ anonId, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });++ let dayRows = await db.getAnonChatMessagesForAi({ anonId, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });let has24h = dayRows.length > 0;-- if (!dayRows.length) dayRows = db.getAnonChatMessagesForAi({ anonId, order: 'desc', limit: 80 });++ if (!dayRows.length) dayRows = await db.getAnonChatMessagesForAi({ anonId, order: 'desc', limit: 80 });if (!dayRows.length) return false;const priorMemory = prior ? (prior.memory_json || '') : '';@@@ -306,7 +306,7 @@ async function _refreshAnon(anonId) {if (!parsed) return false;const nowIso = _sqlTime(now);-- db.upsertChatAiSummary({++ await db.upsertChatAiSummary({scope: 'anon', subject_id: subjectId, window: 'rolling',overview: JSON.stringify({today: _clip(parsed.overview_24h || '', 1200),@@@ -317,7 +317,7 @@ async function _refreshAnon(anonId) {timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),message_count: dayRows.length,window_message_count: dayRows.length,-- last_message_id: db.getMaxChatMessageId(),++ last_message_id: await db.getMaxChatMessageId(),window_label: has24h ? 'past 24h' : 'recent',window_start: has24h ? dayStart : null,window_end: nowIso,@@@ -331,7 +331,7 @@ async function _anonPass() {const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);let candidates = [];try {-- candidates = db.getAnonsNeedingChatAi({ threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK });++ candidates = await db.getAnonsNeedingChatAi({ threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK });} catch (e) { console.warn('[ChatAI] anon discovery failed:', e.message); return; }for (const c of candidates) {if (!c.anon_id) continue;@@@ -365,12 +365,12 @@ async function _tick() {// One-time: backfill the growing timeline log from the existing summary JSON.async function _seedTimelineEvents() {try {-- if ((db.getChatTimelineEvents({ scope: 'global', limit: 1 }) || []).length) return;-- const row = db.getChatAiSummary('global', 0, 'global');++ if ((await db.getChatTimelineEvents({ scope: 'global', limit: 1 }) || []).length) return;++ const row = await db.getChatAiSummary('global', 0, 'global');if (!row) return;let tl = []; try { tl = JSON.parse(row.timeline_json || '[]'); } catch { tl = []; }const cleaned = _cleanTimeline(tl);-- if (cleaned.length) { db.addChatTimelineEvents('global', 0, cleaned); console.log(`[ChatAI] Seeded ${cleaned.length} timeline event(s)`); }++ if (cleaned.length) { await db.addChatTimelineEvents('global', 0, cleaned); console.log(`[ChatAI] Seeded ${cleaned.length} timeline event(s)`); }} catch { /* */ }}@@@ -406,8 +406,8 @@ function _parseOverviews(row) {};}--function getGlobalInsight() {-- const row = db.getChatAiSummary('global', 0, 'global');++async function getGlobalInsight() {++ const row = await db.getChatAiSummary('global', 0, 'global');if (!row) return null;let timeline = [];try { timeline = _cleanTimeline(JSON.parse(row.timeline_json || '[]')); } catch { /* */ }@@@ -422,18 +422,18 @@ function getGlobalInsight() {};}--function getUserInsight(userId) {-- const row = db.getChatAiSummary('user', userId, 'rolling');++async function getUserInsight(userId) {++ const row = await db.getChatAiSummary('user', userId, 'rolling');return row ? _parseOverviews(row) : null;}--function getRelayUserInsight(relayId) {-- const row = db.getChatAiSummary('relay', relayId, 'rolling');++async function getRelayUserInsight(relayId) {++ const row = await db.getChatAiSummary('relay', relayId, 'rolling');return row ? _parseOverviews(row) : null;}--function getAnonInsight(anonId) {-- const row = db.getChatAiSummary('anon', db.anonSubjectId(anonId), 'rolling');++async function getAnonInsight(anonId) {++ const row = await db.getChatAiSummary('anon', db.anonSubjectId(anonId), 'rolling');return row ? _parseOverviews(row) : null;}@@@ -105,10 +105,10 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve// without; `live_sync` is optional, because chat keeps flowing from warm caches while Live is// down (live-context.js), so a stale sync degrades the service instead of taking it out.-- function timed(fn) {++ async function timed(fn) {const t0 = process.hrtime.bigint();let out;-- try { out = fn(); } catch (err) { out = { ok: false, error: String((err && err.message) || err).split('\n')[0].slice(0, 200) }; }++ try { out = await fn(); } catch (err) { out = { ok: false, error: String((err && err.message) || err).split('\n')[0].slice(0, 200) }; }const { ok, error, detail } = out;return {status: ok ? 'ok' : 'fail', required: false,@@@ -132,8 +132,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServeapp.get('/ready', async (req, res) => {const checks = {// A real read of a chat table (MAX of the rowid is an index seek, not a scan).-- db: { ...timed(() => ({ ok: true, detail: { max_message_id: db.get('SELECT MAX(id) AS id FROM chat_messages').id } })), required: true },-- live_sync: timed(() => {++ db: { ...await timed(async () => ({ ok: true, detail: { max_message_id: (await db.get('SELECT MAX(id) AS id FROM chat_messages')).id } })), required: true },++ live_sync: await timed(() => {const s = ctx.syncStatus(config.live.syncStaleMs);const detail = { ...s, threshold_ms: config.live.syncStaleMs };if (s.last_success_at === null) return { ok: false, error: 'no successful Live sync since start', detail };@@@ -152,7 +152,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServeconst degraded = Object.keys(checks).filter((k) => !checks[k].required && checks[k].status !== 'ok');const ready = failed.length === 0;let pending = null;-- if (mirror && checks.db.status === 'ok') { try { pending = mirror.pending(); } catch { /* reported by db */ } }++ if (mirror && checks.db.status === 'ok') { try { pending = await mirror.pending(); } catch { /* reported by db */ } }res.set('Cache-Control', 'no-store');res.status(ready ? 200 : 503).json({ready,@@@ -179,8 +179,8 @@ async function optionalAuth(req, res, next) {/*** Express middleware — requires admin role*/--function requireAdmin(req, res, next) {-- requireAuth(req, res, () => {++async function requireAdmin(req, res, next) {++ await requireAuth(req, res, () => {if (!require('./permissions').isAdmin(req.user)) {return res.status(403).json({ error: 'Admin access required' });}@@@ -51,10 +51,10 @@ function verify(token, publicKey, { issuer = config.networkUrl, now = Date.now()}/** The projected user for a verified token, or null when the projection cannot say which one. */--function userFor(claims) {++async function userFor(claims) {const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;if (!subject) return null;-- const rows = db.all('SELECT * FROM ctx_users WHERE subject_id = ? LIMIT 2', [subject]);++ const rows = await db.all('SELECT * FROM ctx_users WHERE subject_id = ? LIMIT 2', [subject]);if (rows.length !== 1) return null;const row = rows[0];const user = {@@@ -82,19 +82,19 @@ async function authenticate(token) {const u = await ctx.authenticate(token);// Live resolved a Network token (first visit, another key): the same cutoff applies.const iat = u && u.auth_source !== 'api_token' ? tokenIat(token) : null;-- if (iat != null && iat * 1000 < revocations.cutoffFor(u.subject_id)) return refuse(token);++ if (iat != null && iat * 1000 < await revocations.cutoffFor(u.subject_id)) return refuse(token);return u;};-- if (String(token).startsWith('hbt_')) return viaLive();++ if (String(token).startsWith('hbt_')) return await viaLive();const key = await serviceAuth.ensureKey();-- if (!key) return viaLive();++ if (!key) return await viaLive();const r = verify(token, key);// Only a token signed by the key we hold is decided here; anything else is Live's to judge.-- if (!r.ok) return r.reason === 'signature' || r.reason === 'malformed' ? viaLive() : refuse(token);++ if (!r.ok) return r.reason === 'signature' || r.reason === 'malformed' ? await viaLive() : refuse(token);// Signed out everywhere, password changed, banned…: Network's cutoff for this person (WS-B task 4).-- if (revocations.isRevoked(r.claims)) return refuse(token);-- const user = userFor(r.claims);-- if (!user) return viaLive();++ if (await revocations.isRevoked(r.claims)) return refuse(token);++ const user = await userFor(r.claims);++ if (!user) return await viaLive();stats.local++;return user;}@@@ -58,35 +58,35 @@ function isStreamer(user) {/*** Is this user a channel moderator for the given channel?*/--function isChannelMod(user, channelId) {++async function isChannelMod(user, channelId) {if (!user?.id || !channelId) return false;-- return !!ctx.isChannelModerator(user.id, channelId);++ return !!await ctx.isChannelModerator(user.id, channelId);}/*** Is this user the owner of the given channel?*/--function isChannelOwner(user, channelId) {++async function isChannelOwner(user, channelId) {if (!user?.id || !channelId) return false;-- const channel = ctx.getChannelById(channelId);++ const channel = await ctx.getChannelById(channelId);return channel?.user_id === user.id;}/*** Does a stream belong to this user?*/--function isStreamOwner(user, streamId) {++async function isStreamOwner(user, streamId) {if (!user?.id || !streamId) return false;-- const stream = ctx.getStreamById(streamId);++ const stream = await ctx.getStreamById(streamId);return stream?.user_id === user.id;}/*** Get the channel_id for a given stream.*/--function getChannelIdForStream(streamId) {++async function getChannelIdForStream(streamId) {if (!streamId) return null;-- const stream = ctx.getStreamById(streamId);++ const stream = await ctx.getStreamById(streamId);return stream?.channel_id || null;}@@@ -97,11 +97,11 @@ function getChannelIdForStream(streamId) {** True for: admin, global_mod, channel owner, channel mod*/--function canModerateChannel(user, channelId) {++async function canModerateChannel(user, channelId) {if (!user) return false;if (can(user, 'staff.moderation.chat')) return true;-- if (isChannelOwner(user, channelId)) return true;-- return isChannelMod(user, channelId);++ if (await isChannelOwner(user, channelId)) return true;++ return await isChannelMod(user, channelId);}/**@@@ -109,12 +109,12 @@ function canModerateChannel(user, channelId) {** Resolves stream → channel, then checks channel moderation.*/--function canModerateStream(user, streamId) {++async function canModerateStream(user, streamId) {if (!user) return false;if (can(user, 'staff.moderation.chat')) return true;-- if (isStreamOwner(user, streamId)) return true;-- const channelId = getChannelIdForStream(streamId);-- if (channelId && isChannelMod(user, channelId)) return true;++ if (await isStreamOwner(user, streamId)) return true;++ const channelId = await getChannelIdForStream(streamId);++ if (channelId && await isChannelMod(user, channelId)) return true;return false;}@@@ -122,8 +122,8 @@ function canModerateStream(user, streamId) {* Can this user moderate a call on a specific stream?* Same rules as chat moderation.*/--function canModerateCall(user, streamId) {-- return canModerateStream(user, streamId);++async function canModerateCall(user, streamId) {++ return await canModerateStream(user, streamId);}/**@@@ -11,26 +11,17 @@const db = require('../db/database');--let ready = false;const cache = new Map(); // subject → valid_after ms (0 = none known)--function ensureSchema() {-- if (ready) return;-- db.getDb().exec(`CREATE TABLE IF NOT EXISTS token_revocations (-- subject_id TEXT PRIMARY KEY,-- valid_after_ms INTEGER NOT NULL,-- reason TEXT,-- updated_at INTEGER NOT NULL-- )`);-- ready = true;--}++// The table is in migrations/0001_initial.sql (PostgreSQL); nothing is created at runtime. Kept for callers.++function ensureSchema() {}/** The cutoff for a subject in ms, 0 when none. */--function cutoffFor(subject) {++async function cutoffFor(subject) {if (!subject) return 0;if (cache.has(subject)) return cache.get(subject);ensureSchema();-- const row = db.get('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?', [subject]);++ const row = await db.get('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?', [subject]);const ms = row ? Number(row.valid_after_ms) || 0 : 0;if (cache.size > 50000) cache.clear();cache.set(subject, ms);@@@ -38,11 +29,11 @@ function cutoffFor(subject) {}/** Keep the later cutoff (events can arrive out of order). Returns true when it moved forward. */--function record(subject, validAfterMs, reason = null, now = Date.now()) {++async function record(subject, validAfterMs, reason = null, now = Date.now()) {ensureSchema();-- const current = cutoffFor(subject);++ const current = await cutoffFor(subject);if (!(validAfterMs > current)) return false;-- db.run(`INSERT INTO token_revocations (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?)++ await db.run(`INSERT INTO token_revocations (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?)ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_atWHERE excluded.valid_after_ms > token_revocations.valid_after_ms`, [subject, validAfterMs, reason, now]);cache.set(subject, validAfterMs);@@@ -50,12 +41,12 @@ function record(subject, validAfterMs, reason = null, now = Date.now()) {}/** Was a token with these claims issued before its subject's cutoff? */--function isRevoked(claims) {++async function isRevoked(claims) {if (!claims || typeof claims.iat !== 'number') return false;-- return claims.iat * 1000 < cutoffFor(claims.subject_id);++ return claims.iat * 1000 < await cutoffFor(claims.subject_id);}/** For tests: forget the in-memory cache (the table stays). */--function _reset() { cache.clear(); ready = false; }++function _reset() { cache.clear(); }module.exports = { ensureSchema, cutoffFor, record, isRevoked, _reset };@@@ -118,25 +118,25 @@ function createBridge({ chatServer, mirror = null, config = require('../config')const router = express.Router();const refs = new Map(); // `${boot}|${ref}` → { id, at }-- function sweepRefs() {++ async function sweepRefs() {const cutoff = Date.now() - REF_TTL_MS;for (const [k, v] of refs) if (v.at < cutoff) refs.delete(k);-- try { db.run('DELETE FROM bridge_applied WHERE applied_at < ?', [Date.now() - 7 * 24 * 3600 * 1000]); } catch { /* */ }-- try { db.run('DELETE FROM bridge_refs WHERE at < ?', [Date.now() - REF_KEEP_MS]); } catch { /* */ }++ try { await db.run('DELETE FROM bridge_applied WHERE applied_at < ?', [Date.now() - 7 * 24 * 3600 * 1000]); } catch { /* */ }++ try { await db.run('DELETE FROM bridge_refs WHERE at < ?', [Date.now() - REF_KEEP_MS]); } catch { /* */ }}-- function rememberRef(boot, ref, id) {++ async function rememberRef(boot, ref, id) {const at = Date.now();refs.set(`${boot}|${ref}`, { id, at });-- db.run('INSERT INTO bridge_refs (boot, ref, id, at) VALUES (?, ?, ?, ?) ON CONFLICT(boot, ref) DO UPDATE SET id = excluded.id, at = excluded.at', [boot, ref, id, at]);++ await db.run('INSERT INTO bridge_refs (boot, ref, id, at) VALUES (?, ?, ?, ?) ON CONFLICT(boot, ref) DO UPDATE SET id = excluded.id, at = excluded.at', [boot, ref, id, at]);}/** The real id for a placeholder of this Live boot: memory first, then bridge_refs (a restart). */-- function lookupRef(boot, ref) {++ async function lookupRef(boot, ref) {const hit = refs.get(`${boot}|${ref}`);if (hit) return hit.id;let row = null;-- try { row = db.get('SELECT id FROM bridge_refs WHERE boot = ? AND ref = ?', [boot, ref]); } catch { row = null; }++ try { row = await db.get('SELECT id FROM bridge_refs WHERE boot = ? AND ref = ?', [boot, ref]); } catch { row = null; }if (!row) return null;refs.set(`${boot}|${ref}`, { id: row.id, at: Date.now() });return row.id;@@@ -144,20 +144,20 @@ function createBridge({ chatServer, mirror = null, config = require('../config')const sweep = setInterval(sweepRefs, 60_000);if (sweep.unref) sweep.unref();-- function mapRefs(value, boot) {++ async function mapRefs(value, boot) {if (typeof value === 'number') {-- if (value <= REF_MIN) { const id = lookupRef(boot, value); return id != null ? id : value; }++ if (value <= REF_MIN) { const id = await lookupRef(boot, value); return id != null ? id : value; }return value;}if (typeof value === 'string') {const m = /^(m|ov-)(-\d{13,})$/.exec(value);-- if (m) { const id = lookupRef(boot, Number(m[2])); return id != null ? `${m[1]}${id}` : value; }++ if (m) { const id = await lookupRef(boot, Number(m[2])); return id != null ? `${m[1]}${id}` : value; }return value;}-- if (Array.isArray(value)) return value.map((v) => mapRefs(v, boot));++ if (Array.isArray(value)) return (await Promise.all(value.map(async (v) => await mapRefs(v, boot))));if (value && typeof value === 'object') {const out = {};-- for (const [k, v] of Object.entries(value)) out[k] = mapRefs(v, boot);++ for (const [k, v] of Object.entries(value)) out[k] = await mapRefs(v, boot);return out;}return value;@@@ -169,24 +169,24 @@ function createBridge({ chatServer, mirror = null, config = require('../config')};async function runOp(op, rawArgs, boot, ref, key) {-- const args = mapRefs(Array.isArray(rawArgs) ? rawArgs : [], boot);++ const args = await mapRefs(Array.isArray(rawArgs) ? rawArgs : [], boot);if (op === 'db') {const [fn, ...fnArgs] = args;if (!DB_OPS.has(fn) || typeof db[fn] !== 'function') throw new Error(`db.${fn} is not a bridge write`);// saveChatMessage derives the channel from the stream: have the stream first.if (fn === 'saveChatMessage' && fnArgs[0] && fnArgs[0].stream_id) await ctx.ensureStream(fnArgs[0].stream_id);-- const remember = (result) => {-- if (ref != null && result && result.lastInsertRowid != null) rememberRef(boot, ref, Number(result.lastInsertRowid));++ const remember = async (result) => {++ if (ref != null && result && result.lastInsertRowid != null) await rememberRef(boot, ref, Number(result.lastInsertRowid));return result;};-- if (!key) return plain(remember(db[fn](...fnArgs)));++ if (!key) return plain(await remember(await db[fn](...fnArgs)));// Applied once per idempotency key, in the same transaction as the write.-- return db.transaction(() => {-- const done = db.get('SELECT result FROM bridge_applied WHERE key = ?', [key]);-- if (done) { const prev = done.result ? JSON.parse(done.result) : null; return remember(prev); }-- const result = plain(db[fn](...fnArgs));-- db.run('INSERT INTO bridge_applied (key, result, applied_at) VALUES (?, ?, ?)', [key, JSON.stringify(result === undefined ? null : result), Date.now()]);-- return remember(result);++ return await db.tx(async () => {++ const done = await db.get('SELECT result FROM bridge_applied WHERE key = ?', [key]);++ if (done) { const prev = done.result ? JSON.parse(done.result) : null; return await remember(prev); }++ const result = plain(await db[fn](...fnArgs));++ await db.run('INSERT INTO bridge_applied (key, result, applied_at) VALUES (?, ?, ?)', [key, JSON.stringify(result === undefined ? null : result), Date.now()]);++ return await remember(result);});}if (Object.prototype.hasOwnProperty.call(SERVER_OPS, op)) {@@@ -195,25 +195,25 @@ function createBridge({ chatServer, mirror = null, config = require('../config')if (op === 'triggerChannelSound') {// (ws, client, stream, command, args, relay) — Live passes no socket.const [, client, stream, command, soundArgs, relay] = args;-- return plain(chatServer.triggerChannelSound(null, client || {}, stream, command, soundArgs || [], relay || null));++ return plain(await chatServer.triggerChannelSound(null, client || {}, stream, command, soundArgs || [], relay || null));}-- return plain(chatServer[op](...args));++ return plain(await chatServer[op](...args));}switch (op) {case 'broadcastAllRaw': {// Live code that iterated chatServer.clients and sent a pre-serialized payload.let data;try { data = JSON.parse(String(args[0])); } catch { throw new Error('broadcastAllRaw: not JSON'); }-- return plain(chatServer.broadcastAll(data));++ return plain(await chatServer.broadcastAll(data));}case 'deployNotice':-- return require('../chat/deploy-notice').announceCommits({ db, chatServer, commits: args[0] });++ return await require('../chat/deploy-notice').announceCommits({ db, chatServer, commits: args[0] });case 'playAlertSound': {// Live's donation / goal-reached alerts: Live says "play the alert for channel X"// (streamerId, streamId, kind), Chat resolves the sound from its own settings row and// broadcasts it to the channel room. Replaces Live reading the file + sending base64.const [streamerId, streamId, kind] = args;-- return require('../chat/alert-sounds').playAlertSound(chatServer, Number(streamerId) || 0, streamId != null ? Number(streamId) : null, kind === 'goal' ? 'goal' : 'donation');++ return await require('../chat/alert-sounds').playAlertSound(chatServer, Number(streamerId) || 0, streamId != null ? Number(streamId) : null, kind === 'goal' ? 'goal' : 'donation');}case 'userChanged':ctx.invalidateUser(args[0]);@@@ -222,7 +222,7 @@ function createBridge({ chatServer, mirror = null, config = require('../config')case 'invalidate': {// Live wrote data Chat caches (channel moderators/settings, IP approvals, bans, a user).const [kind, id] = args;-- if (kind === 'channel') ctx.invalidateChannel(id);++ if (kind === 'channel') await ctx.invalidateChannel(id);else if (kind === 'approvals') ctx.invalidateApprovals(id);else if (kind === 'bans') await ctx.invalidateBans();else if (kind === 'user') ctx.invalidateUser(id);@@@ -256,7 +256,7 @@ function createBridge({ chatServer, mirror = null, config = require('../config')res.json({ ok: true, results });});-- router.get('/presence', serviceAuth.guard('chat.presence.read'), (req, res) => {++ router.get('/presence', serviceAuth.guard('chat.presence.read'), async (req, res) => {const streams = {};const users = [];const anons = [];@@@ -270,7 +270,7 @@ function createBridge({ chatServer, mirror = null, config = require('../config')at: new Date().toISOString(),total: chatServer.getTotalConnections(),streams,-- slow_mode: Object.fromEntries(chatServer.slowModeByStream),++ slow_mode: Object.fromEntries(await chatServer.slowModeByStream()),users,anons,});@@@ -6,8 +6,9 @@* and rollback means Live becomes the authority again. So every change Chat makes to its tables* (C-02) is copied into Live's tables of the same name, same ids:*-- * - this connection's TEMP triggers (db.initDb({ captureMirror: true })) record each change in-- * live_mirror_outbox — writes by the importer (rows that came from Live) are never recorded;++ * - PostgreSQL triggers on the twelve tables (migrations/0001_initial.sql) record each change in++ * live_mirror_outbox in the writing transaction — skipped where the transaction set ov.mirror_skip = '1'++ * (the importers: rows that came from Live are never recorded);* - this relay sends batches to Live's POST /internal/chat-effects/mirror (capability* live.chat_mirror.write) as { changes: [{ table, op: 'upsert', row } | { table, op: 'delete', pk }] }* with the row as it is NOW (so repeated changes to one row collapse into one upsert);@@@ -26,14 +27,30 @@ const BATCH = 200;const MAX_BODY = 800 * 1024; // Live parses JSON bodies up to 1 MBconst SCOPE = 'live.chat_mirror.write';++// The row as it is now, per mirrored table (static statements, keyed as db.CHAT_TABLES).++const ROW_SQL = {++ chat_messages: 'SELECT * FROM chat_messages WHERE id = ?',++ dm_conversations: 'SELECT * FROM dm_conversations WHERE id = ?',++ dm_participants: 'SELECT * FROM dm_participants WHERE id = ?',++ dm_messages: 'SELECT * FROM dm_messages WHERE id = ?',++ dm_blocks: 'SELECT * FROM dm_blocks WHERE id = ?',++ tts_voice_overrides: 'SELECT * FROM tts_voice_overrides WHERE identity_key = ?',++ channel_sounds: 'SELECT * FROM channel_sounds WHERE id = ?',++ relay_users: 'SELECT * FROM relay_users WHERE id = ?',++ hidden_relay_users: 'SELECT * FROM hidden_relay_users WHERE id = ?',++ pending_ip_messages: 'SELECT * FROM pending_ip_messages WHERE id = ?',++ stream_first_chats: 'SELECT * FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',++ moderation_actions: 'SELECT * FROM moderation_actions WHERE id = ?',++};++function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), log = console } = {}) {let timer = null;let busy = false;let lastError = null;-- function pending() { return db.get('SELECT COUNT(*) AS n FROM live_mirror_outbox')?.n || 0; }++ async function pending() { return (await db.get('SELECT COUNT(*) AS n FROM live_mirror_outbox'))?.n || 0; }-- function buildChanges(rows) {++ async function buildChanges(rows) {// Newest state per row: several changes to one key collapse into the last op.const byKey = new Map();for (const r of rows) byKey.set(`${r.tbl}|${r.pk}`, r);@@@ -44,9 +61,8 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lolet pk;try { pk = JSON.parse(r.pk); } catch { continue; }if (r.op === 'delete') { changes.push({ table: r.tbl, op: 'delete', pk }); continue; }-- const where = cols.map((c) => `${c} = ?`).join(' AND ');-- const row = db.get(`SELECT * FROM ${r.tbl} WHERE ${where}`, cols.map((c) => pk[c]));-- changes.push(row ? { table: r.tbl, op: 'upsert', row } : { table: r.tbl, op: 'delete', pk });++ const row = await db.get(ROW_SQL[r.tbl], cols.map((c) => pk[c]));++ changes.push(row ? { table: r.tbl, op: 'upsert', row: r.tbl === 'moderation_actions' ? db.moderationRow(row) : r.tbl === 'chat_messages' ? db.chatMessageRow(row) : row } : { table: r.tbl, op: 'delete', pk });}return changes;}@@@ -58,9 +74,9 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lolet sent = 0;try {for (;;) {-- const rows = db.all('SELECT seq, tbl, op, pk FROM live_mirror_outbox ORDER BY seq LIMIT ?', [BATCH]);++ const rows = await db.all('SELECT seq, tbl, op, pk FROM live_mirror_outbox ORDER BY seq LIMIT ?', [BATCH]);if (!rows.length) break;-- const changes = buildChanges(rows);++ const changes = await buildChanges(rows);// Requests stay under Live's body limit; a batch is acknowledged when all its parts are.const parts = [[]];let size = 0;@@@ -91,17 +107,26 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lo}if (failed) break;const maxSeq = rows[rows.length - 1].seq;-- db.run('DELETE FROM live_mirror_outbox WHERE seq <= ?', [maxSeq]);++ await db.run('DELETE FROM live_mirror_outbox WHERE seq <= ?', [maxSeq]);sent += changes.length;lastError = null;}} finally { busy = false; }-- if (lastError) log.warn(`[Mirror] Live mirror waiting (${pending()} pending): ${lastError}`);-- return { sent, pending: pending(), error: lastError };++ if (lastError) log.warn(`[Mirror] Live mirror waiting (${await pending()} pending): ${lastError}`);++ return { sent, pending: await pending(), error: lastError };}++ // The triggers record every change; with the mirror off (rehearsals, tests) nothing is kept, as when the capture++ // was per connection and off: the queue is emptied on the same cadence instead of sent.++ async function discard() { await db.run('DELETE FROM live_mirror_outbox'); }++function start() {-- if (timer || !config.live.mirror) return;++ if (timer) return;++ if (!config.live.mirror) {++ timer = setInterval(() => { discard().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);++ if (timer.unref) timer.unref();++ return;++ }timer = setInterval(() => { flush().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);if (timer.unref) timer.unref();}@@@ -83,7 +83,7 @@ class CallServer {});}-- init(server) {++ async init(server) {this.wss = new WebSocket.Server({ noServer: true, maxPayload: 256 * 1024, perMessageDeflate: false });if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);this._heartbeatInterval = setInterval(() => {@@@ -95,21 +95,21 @@ class CallServer {});}, WS_HEARTBEAT_MS);if (this._heartbeatInterval.unref) this._heartbeatInterval.unref();-- this.wss.on('connection', (ws, req) => this._handleConnection(ws, req));++ this.wss.on('connection', async (ws, req) => await this._handleConnection(ws, req));// Cleanup user-created channels that have been empty for over an hourif (this._inactivityTimer) clearInterval(this._inactivityTimer);-- this._inactivityTimer = setInterval(() => this._cleanupInactiveChannels(), 5 * 60 * 1000);++ this._inactivityTimer = setInterval(async () => await this._cleanupInactiveChannels(), 5 * 60 * 1000);if (this._inactivityTimer.unref) this._inactivityTimer.unref();// Rooms are memory: calls the previous process left open are over.-- try { lifecycle.recover(); } catch (err) { console.warn('[Calls] recover failed:', err.message); }++ try { await lifecycle.recover(); } catch (err) { console.warn('[Calls] recover failed:', err.message); }console.log(`[CallServer] Voice channels initialized (${config.calls.enabled ? 'serving' : 'off: CHAT_CALLS is not set'})`);return this.wss;}-- _cleanupInactiveChannels() {++ async _cleanupInactiveChannels() {const ONE_HOUR = 60 * 60 * 1000;const now = Date.now();for (const [k, until] of this.kickCooldown) if (until <= now) this.kickCooldown.delete(k);@@@ -121,7 +121,7 @@ class CallServer {console.log(`[CallServer] Auto-deleting inactive channel "${ch.name}" (${channelId}) — empty for ${Math.round((now - ch.lastEmptiedAt) / 60000)}m`);this.channels.delete(channelId);this.callBans.delete(channelId);-- lifecycle.channelClosed(channelId, 'inactive');++ await lifecycle.channelClosed(channelId, 'inactive');}}}@@@ -135,11 +135,11 @@ class CallServer {/* ── Channel management ──────────────────────────────────── *//** Channels a viewer may see: a private call only for its creator, its invitees and staff. */-- listChannels(viewer = null) {++ async listChannels(viewer = null) {const result = [];for (const [id, ch] of this.channels) {if (ch.roomSlug) continue; // call rooms are listed as rooms (openvibe.chat), not in the voice sidebar-- if (ch.private && !this._canSeePrivate(ch, viewer)) continue;++ if (ch.private && !await this._canSeePrivate(ch, viewer)) continue;const room = this.rooms.get(id);const participants = [];if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }@@@ -148,8 +148,8 @@ class CallServer {return result;}_publicChannel(ch) { const { invited, ...rest } = ch; return rest; }-- _canSeePrivate(ch, viewer) {-- if (ch.roomSlug) { const room = this._roomOf(ch.id); return !!room && rooms.access(room, viewer).read; }++ async _canSeePrivate(ch, viewer) {++ if (ch.roomSlug) { const room = await this._roomOf(ch.id); return !!room && (await rooms.access(room, viewer)).read; }if (!ch.private) return true;if (!viewer) return false;if (permissions.can(viewer, 'staff.moderation.calls')) return true;@@@ -170,24 +170,24 @@ class CallServer {/** Debounced "the list changed" for every chat socket (the sidebar used to poll every 4 s). */_notifyChannelsChanged() {if (this._notifyTimer) return;-- this._notifyTimer = setTimeout(() => {++ this._notifyTimer = setTimeout(async () => {this._notifyTimer = null;-- try { chatServer.broadcastAll({ type: 'voice-channels', channels: this.listChannels(null) }); } catch { /* */ }++ try { await chatServer.broadcastAll({ type: 'voice-channels', channels: await this.listChannels(null) }); } catch { /* */ }}, 400);if (this._notifyTimer.unref) this._notifyTimer.unref();}/** The call room behind a `room-<slug>` channel id, or null. */-- _roomOf(channelId) {++ async _roomOf(channelId) {const m = ROOM_CHANNEL.exec(String(channelId || ''));if (!m) return null;-- const room = rooms.bySlug(m[1]);++ const room = await rooms.bySlug(m[1]);return room && room.kind === 'call' ? room : null;}/** The channel of a call room, made on first use and named after the room. → channel | null */-- ensureRoomChannel(channelId) {-- const room = this._roomOf(channelId);++ async ensureRoomChannel(channelId) {++ const room = await this._roomOf(channelId);if (!room) return null;let ch = this.channels.get(channelId);if (!ch) {@@@ -200,9 +200,9 @@ class CallServer {return ch;}-- getChannel(channelId, viewer = null) {++ async getChannel(channelId, viewer = null) {const ch = this.channels.get(channelId);-- if (!ch || !this._canSeePrivate(ch, viewer)) return null;++ if (!ch || !await this._canSeePrivate(ch, viewer)) return null;const room = this.rooms.get(channelId);const participants = [];if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }@@@ -231,11 +231,11 @@ class CallServer {}/** Live's go-live / call-mode hook. `stream` is the stream row when the caller has it (its title). */-- createStreamChannel(streamId, mode, streamerId, stream = null) {++ async createStreamChannel(streamId, mode, streamerId, stream = null) {const id = `stream-${streamId}`;const existing = this.channels.get(id);-- if (existing) { const old = existing.mode; existing.mode = mode; if (old !== mode) this.endCall(id); return existing; }-- if (!stream) stream = ctx.getStreamById(streamId);++ if (existing) { const old = existing.mode; existing.mode = mode; if (old !== mode) await this.endCall(id); return existing; }++ if (!stream) stream = await ctx.getStreamById(streamId);const ch = {id, name: stream ? (stream.title || `Stream ${streamId}`) : `Stream ${streamId}`,mode: MODES.includes(mode) ? mode : 'mic',@@@ -245,27 +245,27 @@ class CallServer {return ch;}-- removeStreamChannel(streamId) {++ async removeStreamChannel(streamId) {const id = `stream-${streamId}`;const had = this.channels.has(id);-- this.endCall(id, 'stream_ended');++ await this.endCall(id, 'stream_ended');this.channels.delete(id);this.callBans.delete(id);-- lifecycle.channelClosed(id, 'stream_ended');++ await lifecycle.channelClosed(id, 'stream_ended');return had;}/** `user` is the requester (Live passed an id and looked the account up; Chat's routes have it). */-- deleteChannel(channelId, user) {++ async deleteChannel(channelId, user) {const ch = this.channels.get(channelId);if (!ch || ch.permanent || !user) return false;if (ch.createdBy !== user.id) {if (!permissions.can(user, 'staff.moderation.calls')) return false;}-- this.endCall(channelId, 'closed');++ await this.endCall(channelId, 'closed');this.channels.delete(channelId);this.callBans.delete(channelId);-- lifecycle.channelClosed(channelId, 'closed');++ await lifecycle.channelClosed(channelId, 'closed');this._notifyChannelsChanged();return true;}@@@ -295,7 +295,7 @@ class CallServer {};}-- _handleConnection(ws, req) {++ async _handleConnection(ws, req) {const url = new URL(req.url, 'http://localhost');const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');const token = extractWsToken(req);@@@ -311,7 +311,7 @@ class CallServer {// Legacy: bare number → stream-<N>const resolvedId = /^\d+$/.test(channelId) ? `stream-${channelId}` : channelId;-- if (!this.channels.get(resolvedId)) this.ensureRoomChannel(resolvedId);++ if (!this.channels.get(resolvedId)) await this.ensureRoomChannel(resolvedId);if (!this.channels.get(resolvedId)) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }// Sign-in, the anon number and the stream are (cached) calls to Live now. Messages that arrive@@@ -326,7 +326,7 @@ class CallServer {(async () => {const first = this.channels.get(resolvedId);let stream = null;-- if (first && first.streamId) stream = ctx.getStreamById(first.streamId) || await ctx.ensureStream(first.streamId).catch(() => null);++ if (first && first.streamId) stream = await ctx.getStreamById(first.streamId) || await ctx.ensureStream(first.streamId).catch(() => null);const user = await authenticateWs(token).catch(() => null);if (!user) await chatServer._resolveUnifiedAnonNum(chatServer.normalizeIp(ip)).catch(() => {});await ctx.warm({ user, streamId: first && first.streamId ? first.streamId : null, ip }).catch(() => {});@@@ -334,7 +334,7 @@ class CallServer {ws.off('close', onEarlyClose);ws.off('error', onEarlyClose);if (closedEarly || ws.readyState !== WebSocket.OPEN) return;-- this._admit(ws, { resolvedId, ip, user, stream, early });++ await this._admit(ws, { resolvedId, ip, user, stream, early });})().catch((err) => {console.warn('[Call] connection setup failed:', err.message);try { ws.close(1011, 'setup failed'); } catch {}@@@ -342,24 +342,24 @@ class CallServer {}/** The rest of Live's _handleConnection, once the socket's identity is known. */-- _admit(ws, { resolvedId, ip, user, stream, early }) {++ async _admit(ws, { resolvedId, ip, user, stream, early }) {// The channel may have gone while the socket signed in.const channel = this.channels.get(resolvedId);if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }// If stream-linked, verify stream is liveif (channel.streamId) {-- if (!stream || Number(stream.id) !== Number(channel.streamId)) stream = ctx.getStreamById(channel.streamId);++ if (!stream || Number(stream.id) !== Number(channel.streamId)) stream = await ctx.getStreamById(channel.streamId);if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }}-- if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }++ if (channel.private && !await this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }// A call room: the room's roles decide who is in and who talks.let roomAccess = null;if (channel.roomSlug) {-- const room = this._roomOf(resolvedId);-- roomAccess = room ? rooms.access(room, user) : null;++ const room = await this._roomOf(resolvedId);++ roomAccess = room ? await rooms.access(room, user) : null;if (!roomAccess || !roomAccess.read) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }if (!roomAccess.join) { ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); ws.close(); return; }}@@@ -371,8 +371,8 @@ class CallServer {const cooled = this.kickCooldown.get(`${resolvedId}:${identity}`);if (cooled && cooled > Date.now()) { ws.send(JSON.stringify({ type: 'error', message: 'You were removed from this channel; try again in a minute' })); ws.close(); return; }const isChannelCreator = user && channel.createdBy === user.id;-- const isStreamer = channel.streamId ? (user && ctx.getStreamById(channel.streamId)?.user_id === user.id) : false;-- const canModerate = this._canModerate(user, resolvedId);++ const isStreamer = channel.streamId ? (user && (await ctx.getStreamById(channel.streamId))?.user_id === user.id) : false;++ const canModerate = await this._canModerate(user, resolvedId);const bans = this.callBans.get(resolvedId);if (bans) {@@@ -392,7 +392,7 @@ class CallServer {for (const [pid, info] of room) {if (info.user && info.user.id === user.id) {try { info.ws.send(JSON.stringify({ type: 'replaced' })); } catch { /* */ }-- this._handleDisconnect(info.ws, resolvedId, pid);++ await this._handleDisconnect(info.ws, resolvedId, pid);try { info.ws.close(); } catch { /* */ }}}@@@ -419,8 +419,8 @@ class CallServer {}// The lifecycle: the channel's session is active; an invitee joining answers the ring.try {-- if (room.size === 1) lifecycle.sessionStarted({ channelId: resolvedId, streamId: channel.streamId || null, createdBy: channel.createdBy || null });-- if (user) lifecycle.answeredByJoin(resolvedId, user.id);++ if (room.size === 1) await lifecycle.sessionStarted({ channelId: resolvedId, streamId: channel.streamId || null, createdBy: channel.createdBy || null });++ if (user) await lifecycle.answeredByJoin(resolvedId, user.id);} catch (err) { console.warn('[Calls] lifecycle:', err.message); }const participants = [];@@@ -455,22 +455,22 @@ class CallServer {const now = Date.now();if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }if (++clientInfo._msgCount > 200) return;-- queue = queue.then(() => this._handleMessage(ws, JSON.parse(data), resolvedId, peerId))++ queue = queue.then(async () => await this._handleMessage(ws, JSON.parse(data), resolvedId, peerId)).catch((err) => { console.warn('[Call] Message error for peer', peerId, ':', err.message); });};ws.on('message', onMessage);-- ws.on('close', () => this._handleDisconnect(ws, resolvedId, peerId));-- ws.on('error', () => this._handleDisconnect(ws, resolvedId, peerId));++ ws.on('close', async () => await this._handleDisconnect(ws, resolvedId, peerId));++ ws.on('error', async () => await this._handleDisconnect(ws, resolvedId, peerId));for (const data of early) onMessage(data);}-- _canModerate(user, channelId) {++ async _canModerate(user, channelId) {if (!user) return false;if (permissions.can(user, 'staff.moderation.calls')) return true;const ch = this.channels.get(channelId);-- if (ch?.roomSlug) { const room = this._roomOf(channelId); return !!room && rooms.access(room, user).moderate; }++ if (ch?.roomSlug) { const room = await this._roomOf(channelId); return !!room && (await rooms.access(room, user)).moderate; }if (ch?.createdBy === user.id) return true;-- if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);++ if (ch?.streamId) return await permissions.canModerateCall(user, ch.streamId);return false;}@@@ -532,20 +532,20 @@ class CallServer {}const ch = this.channels.get(channelId);if (ch?.roomSlug) {-- const callRoom = this._roomOf(channelId);-- const a = callRoom ? rooms.access(callRoom, user) : null;++ const callRoom = await this._roomOf(channelId);++ const a = callRoom ? await rooms.access(callRoom, user) : null;if (!a || !a.read || !a.join) { if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); c.ws.close(); } break; }}c.user = user; c.anonId = user ? null : chatServer.getAnonIdForConnection(c.ip, channelId);c.isChannelCreator = !!(user && ch?.createdBy === user.id);-- c.isStreamer = ch?.streamId ? !!(user && ctx.getStreamById(ch.streamId)?.user_id === user.id) : false;-- if (user) { try { lifecycle.answeredByJoin(channelId, user.id); } catch { /* */ } }-- if (ch?.roomSlug) { this._applyRoomAccessTo(channelId, peerId, c); if (room.get(peerId) !== c) break; }++ c.isStreamer = ch?.streamId ? !!(user && (await ctx.getStreamById(ch.streamId))?.user_id === user.id) : false;++ if (user) { try { await lifecycle.answeredByJoin(channelId, user.id); } catch { /* */ } }++ if (ch?.roomSlug) { await this._applyRoomAccessTo(channelId, peerId, c); if (room.get(peerId) !== c) break; }const pInfo = this._buildParticipantInfo(peerId, c);if (c.ws.readyState === WebSocket.OPEN) c.ws.send(JSON.stringify({type: 'self-updated',isStreamer: c.isStreamer || c.isChannelCreator,-- canModerate: this._canModerate(user, channelId),++ canModerate: await this._canModerate(user, channelId),participant: pInfo,}));const u = JSON.stringify({ type: 'peer-updated', ...pInfo });@@@ -553,7 +553,7 @@ class CallServer {break;}case 'force-mute': {-- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;++ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;if (target.listenOnly && !msg.forceMuted) break; // make them a speaker in the room insteadtarget.forceMuted = !!msg.forceMuted;@@@ -563,7 +563,7 @@ class CallServer {break;}case 'force-camera-off': {-- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;++ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;if (target.listenOnly && !msg.forceCameraOff) break;target.forceCameraOff = !!msg.forceCameraOff;@@@ -573,7 +573,7 @@ class CallServer {break;}case 'kick': {-- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;++ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;if (target.identity) this.kickCooldown.set(`${channelId}:${target.identity}`, Date.now() + KICK_COOLDOWN_MS);if (target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'kicked' })); target.ws.close(); }@@@ -584,7 +584,7 @@ class CallServer {break;}case 'ban': {-- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;++ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;if (!this.callBans.has(channelId)) this.callBans.set(channelId, new Set());const banSet = this.callBans.get(channelId);@@@ -592,9 +592,9 @@ class CallServer {banSet.add(target.user.id); // backward compatibility with existing entriesbanSet.add(`u:${target.user.id}`);// In a call room the ban is the room's block, so it outlives this process.-- const callRoom = this._roomOf(channelId);++ const callRoom = await this._roomOf(channelId);if (callRoom && sender.user) {-- try { rooms.setRole(callRoom, sender.user, target.user.id, 'blocked'); chatServer.refreshRoomAccess(callRoom); } catch (err) { console.warn('[Call] room block:', err.message); }++ try { await rooms.setRole(callRoom, sender.user, target.user.id, 'blocked'); await chatServer.refreshRoomAccess(callRoom); } catch (err) { console.warn('[Call] room block:', err.message); }}} else {if (target.anonId) banSet.add(`a:${target.anonId}`);@@@ -608,7 +608,7 @@ class CallServer {break;}case 'unban': {-- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;++ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;const uid = parseInt(msg.userId);if (uid && this.callBans.has(channelId)) {this.callBans.get(channelId).delete(uid);@@@ -621,15 +621,18 @@ class CallServer {const ch = this.channels.get(channelId);const canEnd = (ch?.createdBy && c.user?.id === ch.createdBy) || c.isStreamer || permissions.can(c.user, 'staff.moderation.calls');if (canEnd) {-- this.endCall(channelId, 'ended');-- if (ch && !ch.permanent) { this.channels.delete(channelId); lifecycle.channelClosed(channelId, 'ended'); }++ // The channel leaves memory before the database is written, as when the write was synchronous.++ const closing = ch && !ch.permanent;++ if (closing) this.channels.delete(channelId);++ await this.endCall(channelId, 'ended');++ if (closing) await lifecycle.channelClosed(channelId, 'ended');}break;}}}-- _handleDisconnect(ws, channelId, peerId) {++ async _handleDisconnect(ws, channelId, peerId) {if (!this.clients.has(ws)) return; // already handled (error+close fire back-to-back)this.clients.delete(ws);const room = this.rooms.get(channelId);@@@ -649,7 +652,7 @@ class CallServer {if (ch && !ch.permanent && !ch.streamId) {ch.lastEmptiedAt = Date.now();}-- try { lifecycle.channelEmptied(channelId, 'empty'); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }++ try { await lifecycle.channelEmptied(channelId, 'empty'); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }}}@@@ -676,7 +679,7 @@ class CallServer {* what they may do now. A new speaker may talk, a demoted one is muted, someone who may no longer* read the room (blocked, removed from a private room) is dropped. → peers changed*/-- applyRoomAccess(room) {++ async applyRoomAccess(room) {if (!room || room.kind !== 'call') return 0;const channelId = `room-${room.slug}`;const ch = this.channels.get(channelId);@@@ -684,18 +687,18 @@ class CallServer {const r = this.rooms.get(channelId);if (!r) return 0;let changed = 0;-- for (const [pid, info] of [...r]) if (this._applyRoomAccessTo(channelId, pid, info, room)) changed++;++ for (const [pid, info] of [...r]) if (await this._applyRoomAccessTo(channelId, pid, info, room)) changed++;return changed;}/** One peer of a call room against the room's current roles. → whether anything changed */-- _applyRoomAccessTo(channelId, peerId, info, room = null) {-- const callRoom = room || this._roomOf(channelId);-- const a = callRoom ? rooms.access(callRoom, info.user || null) : null;++ async _applyRoomAccessTo(channelId, peerId, info, room = null) {++ const callRoom = room || await this._roomOf(channelId);++ const a = callRoom ? await rooms.access(callRoom, info.user || null) : null;const send = (o) => { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(JSON.stringify(o)); };if (!a || !a.read || !a.join) {send({ type: 'error', message: 'You can no longer join this call' });-- this._handleDisconnect(info.ws, channelId, peerId);++ await this._handleDisconnect(info.ws, channelId, peerId);try { info.ws.close(); } catch { /* */ }return true;}@@@ -707,7 +710,7 @@ class CallServer {info.forceMuted = listenOnly;info.forceCameraOff = listenOnly;if (listenOnly) { info.muted = true; info.cameraOff = true; info.speaking = false; }-- send({ type: 'room-role', room: callRoom.slug, role: a.role, canTalk: a.talk, canModerate: this._canModerate(info.user, channelId) });++ send({ type: 'room-role', room: callRoom.slug, role: a.role, canTalk: a.talk, canModerate: await this._canModerate(info.user, channelId) });send({ type: 'force-muted', forceMuted: info.forceMuted });send({ type: 'force-camera-off', forceCameraOff: info.forceCameraOff });const r = this.rooms.get(channelId);@@@ -725,23 +728,24 @@ class CallServer {return true;}-- endCall(channelId, reason = 'ended') {++ async endCall(channelId, reason = 'ended') {const r = this.rooms.get(channelId); if (!r) return;const entries = [...r.values()];this.rooms.delete(channelId);++ for (const info of entries) this.clients.delete(info.ws);++ // The session row ends before anyone hears call-ended (the order it had when the write was synchronous).++ try { await lifecycle.channelEmptied(channelId, reason); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }const m = JSON.stringify({ type: 'call-ended' });for (const info of entries) {-- this.clients.delete(info.ws);if (info.ws.readyState === WebSocket.OPEN) { info.ws.send(m); info.ws.close(); }}-- try { lifecycle.channelEmptied(channelId, reason); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }}/** Legacy compat: end call by stream ID */-- endStreamCall(streamId) { this.endCall(`stream-${streamId}`); }++ async endStreamCall(streamId) { await this.endCall(`stream-${streamId}`); }-- close() {-- for (const [cid] of this.rooms) this.endCall(cid, 'restart');++ async close() {++ for (const [cid] of this.rooms) await this.endCall(cid, 'restart');if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); this._heartbeatInterval = null; }if (this._inactivityTimer) { clearInterval(this._inactivityTimer); this._inactivityTimer = null; }if (this._notifyTimer) { clearTimeout(this._notifyTimer); this._notifyTimer = null; }@@@ -44,8 +44,8 @@ function createInternalRouter() {try {// Bounded: a slow Live must not hold Live's own go-live; the projection answers meanwhile.const stream = await Promise.race([ctx.refreshStream(streamId), new Promise((r) => setTimeout(() => r(null), STREAM_LOOKUP_MS).unref?.())])-- || ctx.getStreamById(streamId);-- const ch = callServer.createStreamChannel(streamId, mode, userId, stream);++ || await ctx.getStreamById(streamId);++ const ch = await callServer.createStreamChannel(streamId, mode, userId, stream);res.json({ ok: true, channel: callServer._publicChannel(ch) });} catch (err) {console.error('[Calls] stream-channel:', err.message);@@@ -53,11 +53,11 @@ function createInternalRouter() {}});-- router.delete('/stream-channel/:streamId', (req, res) => {++ router.delete('/stream-channel/:streamId', async (req, res) => {const streamId = Number(req.params.streamId);if (!Number.isInteger(streamId) || streamId <= 0) return res.status(400).json({ error: 'stream id required' });try {-- res.json({ ok: true, removed: callServer.removeStreamChannel(streamId) });++ res.json({ ok: true, removed: await callServer.removeStreamChannel(streamId) });} catch (err) {console.error('[Calls] stream-channel:', err.message);res.status(500).json({ error: 'Failed to remove the stream voice channel' });@@@ -53,7 +53,7 @@ function canTransition(from, to) {return !!(TRANSITIONS[from] && TRANSITIONS[from].includes(to));}--function get(id) { return db.get('SELECT * FROM calls WHERE id = ?', [id]) || null; }++async function get(id) { return await db.get('SELECT * FROM calls WHERE id = ?', [id]) || null; }function clearTimer(id) {const t = _timers.get(id);@@@ -61,8 +61,8 @@ function clearTimer(id) {}/** Move a call to `to` when its state allows it. Returns the updated row, or null (nothing written). */--function transition(id, to, { reason = null, now = Date.now() } = {}) {-- const row = get(id);++async function transition(id, to, { reason = null, now = Date.now() } = {}) {++ const row = await get(id);if (!row || !canTransition(row.state, to)) return null;const sets = ['state = ?'];const params = [to];@@@ -72,46 +72,46 @@ function transition(id, to, { reason = null, now = Date.now() } = {}) {if (row.kind === 'direct') { sets.push('answered_at = COALESCE(answered_at, ?)'); params.push(now); }}if (!OPEN.includes(to)) { sets.push('ended_at = ?', 'end_reason = ?'); params.push(now, reason); }-- const r = db.run(`UPDATE calls SET ${sets.join(', ')} WHERE id = ? AND state = ?`, [...params, id, row.state]);++ const r = await db.run(`UPDATE calls SET ${sets.join(', ')} WHERE id = ? AND state = ?`, [...params, id, row.state]);if (r.changes !== 1) return null;if (to !== 'ringing') clearTimer(id);-- return get(id);++ return await get(id);}--function insert({ kind, state, channelId, streamId = null, createdBy = null, targetId = null, now = Date.now() }) {++async function insert({ kind, state, channelId, streamId = null, createdBy = null, targetId = null, now = Date.now() }) {if (!KINDS.includes(kind) || !STATES.includes(state)) throw new Error(`bad call ${kind}/${state}`);-- const r = db.run(`INSERT INTO calls (kind, state, channel_id, stream_id, created_by, created_by_subject, target_user_id, target_subject, created_at, started_at)++ const r = await db.run(`INSERT INTO calls (kind, state, channel_id, stream_id, created_by, created_by_subject, target_user_id, target_subject, created_at, started_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,-- [kind, state, String(channelId), streamId || null, createdBy || null, db.subjectFor(createdBy), targetId || null, db.subjectFor(targetId), now, state === 'active' ? now : null]);-- return get(r.lastInsertRowid);++ [kind, state, String(channelId), streamId || null, createdBy || null, await db.subjectFor(createdBy), targetId || null, await db.subjectFor(targetId), now, state === 'active' ? now : null]);++ return await get(r.lastInsertRowid);}// ── Direct calls ────────────────────────────────────────────────────────────────────────────────/** The caller's open ring to this person on this channel, if any (calling again re-rings it). */--function openDirectFor(callerId, targetId, channelId) {-- return db.get(`SELECT * FROM calls WHERE kind = 'direct' AND created_by = ? AND target_user_id = ? AND channel_id = ?++async function openDirectFor(callerId, targetId, channelId) {++ return await db.get(`SELECT * FROM calls WHERE kind = 'direct' AND created_by = ? AND target_user_id = ? AND channel_id = ?AND state IN ('pending', 'ringing') ORDER BY id DESC LIMIT 1`, [callerId, targetId, String(channelId)]) || null;}/** A ring from callerId to targetId in channelId: the open one again, or a new pending row. */--function openDirect({ callerId, targetId, channelId }) {-- return openDirectFor(callerId, targetId, channelId) || insert({ kind: 'direct', state: 'pending', channelId, createdBy: callerId, targetId });++async function openDirect({ callerId, targetId, channelId }) {++ return await openDirectFor(callerId, targetId, channelId) || await insert({ kind: 'direct', state: 'pending', channelId, createdBy: callerId, targetId });}/*** The invite went out: ringing, and missed after `timeoutMs` unless answered first. `onTimeout(row)`* runs once the row is missed (the call server tells the caller). Ringing again restarts the timer.*/--function ring(id, { timeoutMs, onTimeout = null } = {}) {-- let row = get(id);++async function ring(id, { timeoutMs, onTimeout = null } = {}) {++ let row = await get(id);if (!row) return null;-- if (row.state === 'pending') row = transition(id, 'ringing');++ if (row.state === 'pending') row = await transition(id, 'ringing');if (!row || row.state !== 'ringing') return row;clearTimer(id);-- const t = setTimeout(() => {++ const t = setTimeout(async () => {_timers.delete(id);-- const missed = transition(id, 'missed', { reason: 'timeout' });++ const missed = await transition(id, 'missed', { reason: 'timeout' });if (missed && onTimeout) { try { onTimeout(missed); } catch (err) { console.warn('[Calls] ring timeout:', err.message); } }}, Math.max(1, Number(timeoutMs) || 0));if (t.unref) t.unref();@@@ -120,72 +120,75 @@ function ring(id, { timeoutMs, onTimeout = null } = {}) {}/** The invite could not be delivered. */--function fail(id, reason) {-- return transition(id, 'failed', { reason: String(reason || 'failed').slice(0, 200) });++async function fail(id, reason) {++ return await transition(id, 'failed', { reason: String(reason || 'failed').slice(0, 200) });}/*** The callee answered (a respond status). Returns the row after the answer, or null when there is* no ring to answer (never rang, already over). `accepted` on an active call changes nothing.*/--function respond({ callerId, targetId, channelId, status }) {++async function respond({ callerId, targetId, channelId, status }) {const rule = RESPONSES[status];if (!rule) return null;-- const row = db.get(`SELECT * FROM calls WHERE kind = 'direct' AND created_by = ? AND target_user_id = ? AND channel_id = ?++ const row = await db.get(`SELECT * FROM calls WHERE kind = 'direct' AND created_by = ? AND target_user_id = ? AND channel_id = ?AND state IN ('pending', 'ringing', 'active') ORDER BY id DESC LIMIT 1`, [callerId, targetId, String(channelId)]);if (!row) return null;if (row.state === 'active') return status === 'accepted' ? row : null;-- return transition(row.id, rule[0], { reason: rule[1] });++ return await transition(row.id, rule[0], { reason: rule[1] });}/** The callee joined the caller's channel while it rang: that is an answer too. */--function answeredByJoin(channelId, userId) {++async function answeredByJoin(channelId, userId) {if (!userId) return [];-- const rows = db.all(`SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND target_user_id = ? AND state = 'ringing'`, [String(channelId), userId]);-- return rows.map((r) => transition(r.id, 'active')).filter(Boolean);++ const rows = await db.all(`SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND target_user_id = ? AND state = 'ringing'`, [String(channelId), userId]);++ const out = []; for (const r of rows) out.push(await transition(r.id, 'active'));++ return out.filter(Boolean);}// ── Channel sessions ────────────────────────────────────────────────────────────────────────────/** Somebody is in the channel: its session is active (one per channel at a time). */--function sessionStarted({ channelId, streamId = null, createdBy = null }) {-- const open = db.get(`SELECT * FROM calls WHERE kind IN ('channel', 'stream') AND channel_id = ? AND state = 'active' ORDER BY id DESC LIMIT 1`, [String(channelId)]);++async function sessionStarted({ channelId, streamId = null, createdBy = null }) {++ const open = await db.get(`SELECT * FROM calls WHERE kind IN ('channel', 'stream') AND channel_id = ? AND state = 'active' ORDER BY id DESC LIMIT 1`, [String(channelId)]);if (open) return open;-- return insert({ kind: streamId ? 'stream' : 'channel', state: 'active', channelId, streamId, createdBy });++ return await insert({ kind: streamId ? 'stream' : 'channel', state: 'active', channelId, streamId, createdBy });}/** Nobody is left (or everyone was sent away): the session and every answered ring on it end. */--function channelEmptied(channelId, reason = 'empty') {-- const rows = db.all(`SELECT id FROM calls WHERE channel_id = ? AND state = 'active'`, [String(channelId)]);-- return rows.map((r) => transition(r.id, 'ended', { reason })).filter(Boolean);++async function channelEmptied(channelId, reason = 'empty') {++ const rows = await db.all(`SELECT id FROM calls WHERE channel_id = ? AND state = 'active'`, [String(channelId)]);++ const out = []; for (const r of rows) out.push(await transition(r.id, 'ended', { reason }));++ return out.filter(Boolean);}/** The channel is gone (deleted, ended, stream over): rings still waiting on it end as canceled. */--function channelClosed(channelId, reason = 'closed') {-- const ended = channelEmptied(channelId, reason);-- const rows = db.all(`SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND state IN ('pending', 'ringing')`, [String(channelId)]);-- return ended.concat(rows.map((r) => transition(r.id, 'ended', { reason: 'canceled' })).filter(Boolean));++async function channelClosed(channelId, reason = 'closed') {++ const ended = await channelEmptied(channelId, reason);++ const rows = await db.all(`SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND state IN ('pending', 'ringing')`, [String(channelId)]);++ for (const r of rows) ended.push(await transition(r.id, 'ended', { reason: 'canceled' }));++ return ended.filter(Boolean);}// ── Boot ────────────────────────────────────────────────────────────────────────────────────────/** After a restart no room survives: close whatever the previous process left open. */--function recover() {++async function recover() {const out = { failed: 0, missed: 0, ended: 0 };-- for (const r of db.all(`SELECT id, state FROM calls WHERE state IN ('pending', 'ringing', 'active')`)) {++ for (const r of await db.all(`SELECT id, state FROM calls WHERE state IN ('pending', 'ringing', 'active')`)) {const to = r.state === 'pending' ? 'failed' : r.state === 'ringing' ? 'missed' : 'ended';-- if (transition(r.id, to, { reason: 'restart' })) out[to]++;++ if (await transition(r.id, to, { reason: 'restart' })) out[to]++;}if (out.failed + out.missed + out.ended) console.log(`[Calls] closed calls left open by the last run: ${JSON.stringify(out)}`);return out;}--function list({ channelId = null, kind = null, limit = 50 } = {}) {++async function list({ channelId = null, kind = null, limit = 50 } = {}) {const where = [];const params = [];if (channelId) { where.push('channel_id = ?'); params.push(String(channelId)); }if (kind) { where.push('kind = ?'); params.push(kind); }-- return db.all(`SELECT * FROM calls ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`, [...params, Math.min(Math.max(Number(limit) || 50, 1), 500)]);++ return await db.all(`SELECT * FROM calls ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`, [...params, Math.min(Math.max(Number(limit) || 50, 1), 500)]);}function stop() { for (const id of [..._timers.keys()]) clearTimer(id); }@@@ -44,19 +44,19 @@ const MODES = ['mic', 'mic+cam', 'cam+mic'];/* ── Voice Channels (global, non-stream) ───────────────────── */--router.get('/voice-channels', optionalAuth, (req, res) => {++router.get('/voice-channels', optionalAuth, async (req, res) => {try {res.set('Cache-Control', 'no-store'); // private calls differ per viewer; the list is pushed anyway-- res.json({ channels: callServer.listChannels(req.user || null) });++ res.json({ channels: await callServer.listChannels(req.user || null) });} catch (err) {console.error('[Calls]', err.message);res.status(500).json({ error: 'Failed to list voice channels' });}});--router.get('/voice-channels/:channelId', optionalAuth, (req, res) => {++router.get('/voice-channels/:channelId', optionalAuth, async (req, res) => {try {-- const ch = callServer.getChannel(req.params.channelId, req.user || null);++ const ch = await callServer.getChannel(req.params.channelId, req.user || null);if (!ch) return res.status(404).json({ error: 'Channel not found' });res.json({ channel: ch });} catch (err) {@@@ -77,9 +77,9 @@ router.post('/voice-channels', requireAuth, channelLimit, (req, res) => {}});--router.delete('/voice-channels/:channelId', requireAuth, channelLimit, (req, res) => {++router.delete('/voice-channels/:channelId', requireAuth, channelLimit, async (req, res) => {try {-- const ok = callServer.deleteChannel(req.params.channelId, req.user);++ const ok = await callServer.deleteChannel(req.params.channelId, req.user);if (!ok) return res.status(403).json({ error: 'Cannot delete this channel' });res.json({ deleted: true });} catch (err) {@@@ -92,16 +92,16 @@ router.delete('/voice-channels/:channelId', requireAuth, channelLimit, (req, resasync function findUser(id, username) {let user = null;if (id > 0) {-- user = ctx.getUserById(id);-- if (!user) { await ctx.ensureUsers([id]).catch(() => {}); user = ctx.getUserById(id); }++ user = await ctx.getUserById(id);++ if (!user) { await ctx.ensureUsers([id]).catch(() => {}); user = await ctx.getUserById(id); }}if (!user && username) user = await ctx.ensureUserByUsername(username);return user || null;}/** The caller hears "no answer" when their ring times out (the frame the callee's browser sends). */--function tellCallerNoAnswer(row, channelName, target) {-- chatServer.sendDm(row.created_by, {++async function tellCallerNoAnswer(row, channelName, target) {++ await chatServer.sendDm(row.created_by, {type: 'vc-call-response',status: 'no-answer',channelId: row.channel_id,@@@ -129,11 +129,11 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, resconst targetUser = await findUser(targetUserId, targetUsername);if (!targetUser) return res.status(404).json({ error: 'User not found' });if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });-- try { if (dm.isBlockedEither && dm.isBlockedEither(req.user.id, targetUser.id)) return res.status(403).json({ error: 'You cannot call this user' }); } catch { /* */ }++ try { if (dm.isBlockedEither && await dm.isBlockedEither(req.user.id, targetUser.id)) return res.status(403).json({ error: 'You cannot call this user' }); } catch { /* */ }// Reuse caller's existing temp channel if present; otherwise create a private one — a// 1:1 call is not something the whole site should see listed and be able to walk into.-- const existing = (callServer.listChannels(req.user) || []).find(ch => !ch.permanent && !ch.streamId && ch.createdBy === req.user.id) || null;++ const existing = (await callServer.listChannels(req.user) || []).find(ch => !ch.permanent && !ch.streamId && ch.createdBy === req.user.id) || null;const channel = existing || callServer.createChannel({name: `${req.user.display_name || req.user.username}'s call`,mode: 'mic+cam',@@@ -141,7 +141,7 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, resmaxParticipants: 8,isPrivate: true,});-- call = lifecycle.openDirect({ callerId: req.user.id, targetId: targetUser.id, channelId: channel.id });++ call = await lifecycle.openDirect({ callerId: req.user.id, targetId: targetUser.id, channelId: channel.id });if (!callServer.invite(channel.id, targetUser.id)) throw new Error('the call channel is gone');const callerName = req.user.display_name || req.user.username || 'Someone';@@@ -157,7 +157,7 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res};// Real-time invite for online users via existing chat WS connections.-- chatServer.sendDm(targetUser.id, payload);++ await chatServer.sendDm(targetUser.id, payload);// Persistent cross-site notification for offline users / later join (Live pushes it).ctx.effects.notifyCallInvite({@@@ -167,18 +167,18 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, reschannel_name: channel.name,});-- lifecycle.ring(call.id, { timeoutMs: config.calls.ringTimeoutMs, onTimeout: (row) => tellCallerNoAnswer(row, channel.name, targetUser) });++ await lifecycle.ring(call.id, { timeoutMs: config.calls.ringTimeoutMs, onTimeout: async (row) => await tellCallerNoAnswer(row, channel.name, targetUser) });return res.json({ invited: true, reusedChannel: !!existing, channel });} catch (err) {if (err.code === 'CHANNEL_LIMIT') return res.status(400).json({ error: err.message });-- if (call) { try { lifecycle.fail(call.id, `invite_failed: ${err.message}`); } catch { /* */ } }++ if (call) { try { await lifecycle.fail(call.id, `invite_failed: ${err.message}`); } catch { /* */ } }console.error('[Calls]', err.message);return res.status(500).json({ error: 'Failed to call user' });}});--router.post('/voice-channels/call-user/respond', requireAuth, answerLimit, (req, res) => {++router.post('/voice-channels/call-user/respond', requireAuth, answerLimit, async (req, res) => {try {const callerUserId = Number(req.body?.caller_user_id || 0);const channelId = String(req.body?.channel_id || '').trim();@@@ -191,13 +191,13 @@ router.post('/voice-channels/call-user/respond', requireAuth, answerLimit, (req,if (!allowed.has(status)) return res.status(400).json({ error: 'Invalid response status' });if (callerUserId === req.user.id) return res.status(400).json({ error: 'Invalid caller target' });// Only an invited user can answer, and only to the caller who owns that channel.-- const ch = callServer.getChannel(channelId, req.user);++ const ch = await callServer.getChannel(channelId, req.user);if (!ch || ch.createdBy !== callerUserId || !callServer.hasInvite(channelId, req.user.id)) return res.status(403).json({ error: 'No such invite' });-- try { lifecycle.respond({ callerId: callerUserId, targetId: req.user.id, channelId, status }); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }++ try { await lifecycle.respond({ callerId: callerUserId, targetId: req.user.id, channelId, status }); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }const fromDisplayName = req.user.display_name || req.user.username || 'Someone';-- chatServer.sendDm(callerUserId, {++ await chatServer.sendDm(callerUserId, {type: 'vc-call-response',status,channelId,@@@ -248,9 +248,9 @@ router.put('/:id/call', requireAuth, channelLimit, async (req, res) => {// Create or remove stream voice channelif (call_mode) {-- callServer.createStreamChannel(stream.id, call_mode, stream.user_id, stream);++ await callServer.createStreamChannel(stream.id, call_mode, stream.user_id, stream);} else {-- callServer.removeStreamChannel(stream.id);++ await callServer.removeStreamChannel(stream.id);}res.json(callStatus(stream.id, call_mode));@@@ -262,7 +262,7 @@ router.put('/:id/call', requireAuth, channelLimit, async (req, res) => {router.get('/:id/call', optionalAuth, async (req, res) => {try {-- const stream = ctx.getStreamById(req.params.id) || await ctx.ensureStream(req.params.id);++ const stream = await ctx.getStreamById(req.params.id) || await ctx.ensureStream(req.params.id);if (!stream) return res.status(404).json({ error: 'Stream not found' });const ch = callServer.channels.get(`stream-${stream.id}`);res.json(callStatus(stream.id, ch ? ch.mode : null));@@@ -30,58 +30,39 @@ const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;const TOPICS = ['network.account.export_requested', 'network.account.deleted'];const ROW_LIMIT = 5000;--const cols = (d, t) => { try { return d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name); } catch { return []; } };--const has = (d, t, c) => cols(d, t).includes(c);--const inList = (xs) => `(${xs.map(() => '?').join(',')})`;----function ensureSchema(d) {-- d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (-- id TEXT PRIMARY KEY,-- kind TEXT NOT NULL,-- subject TEXT NOT NULL,-- outcome TEXT,-- sent_at TEXT,-- applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))-- )`);--}++// Static statements per table (plan T3 decision 7: no interpolated identifiers). @s is the person's subjects,++// @ids their Live user ids (either can be empty: `= ANY('{}')` matches nothing)./** The Live user ids of these subjects (Chat's ctx_users mirror). */--function liveIds(d, subjects) {-- return d.prepare(`SELECT id FROM ctx_users WHERE subject_id IN ${inList(subjects)}`).all(...subjects).map((r) => r.id);--}----/** WHERE for a table: its subject column(s) IN subjects, or its Live user column(s) IN ids. → [sql, params] | null. */--function whereFor(d, table, subjectCols, idCols, subjects, ids) {-- const parts = []; const params = [];-- for (const c of subjectCols) if (has(d, table, c)) { parts.push(`${c} IN ${inList(subjects)}`); params.push(...subjects); }-- if (ids.length) for (const c of idCols) if (has(d, table, c)) { parts.push(`${c} IN ${inList(ids)}`); params.push(...ids); }-- return parts.length ? [parts.join(' OR '), params] : null;++async function liveIds(d, subjects) {++ return (await d.prepare('SELECT id FROM ctx_users WHERE subject_id = ANY(?)').all(subjects)).map((r) => r.id);}// ── Export ─────────────────────────────────────────────────────++// [file, rows]: at most ROW_LIMIT + 1 of the most recent (highest key first; SQLite read these by rowid).++const LIMIT = ROW_LIMIT + 1;const EXPORTS = [-- ['chat_messages.json', 'chat_messages', ['subject_id'], ['user_id']],-- ['room_messages.json', 'room_messages', ['subject_id'], ['user_id']],-- ['direct_messages.json', 'dm_messages', ['sender_subject_id'], ['sender_id']],-- ['conversations.json', 'dm_participants', ['subject_id'], ['user_id']],-- ['rooms_owned.json', 'rooms', ['owner_subject'], ['owner_id']],-- ['calls.json', 'calls', ['created_by_subject', 'target_subject'], ['created_by', 'target_user_id']],-- ['blocks.json', 'network_blocks', ['blocker_subject'], []],-- ['dm_blocks.json', 'dm_blocks', ['blocker_subject_id'], ['blocker_id']],-- ['emotes.json', 'emotes', [], ['user_id']],-- ['sounds.json', 'channel_sounds', ['created_by_subject_id'], ['created_by']],-- ['moderation_on_you.json', 'moderation_actions', [], ['target_user_id']],++ ['chat_messages.json', `SELECT * FROM chat_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['room_messages.json', `SELECT * FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['direct_messages.json', `SELECT * FROM dm_messages WHERE sender_subject_id = ANY(@s) OR sender_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['conversations.json', `SELECT * FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['rooms_owned.json', `SELECT * FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],++ ['dm_blocks.json', `SELECT * FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['emotes.json', `SELECT * FROM emotes WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['sounds.json', `SELECT * FROM channel_sounds WHERE created_by_subject_id = ANY(@s) OR created_by = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['moderation_on_you.json', `SELECT * FROM moderation_actions WHERE target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],];--function exportPart(d, subject) {-- const ids = liveIds(d, [subject]);++async function exportPart(d, subject) {++ const ids = await liveIds(d, [subject]);const files = []; const truncated = [];-- for (const [name, table, sc, ic] of EXPORTS) {-- const w = whereFor(d, table, sc, ic, [subject], ids);-- if (!w) continue;-- let rows = d.prepare(`SELECT * FROM ${table} WHERE ${w[0]} ORDER BY rowid DESC LIMIT ${ROW_LIMIT + 1}`).all(...w[1]);-- if (table === 'moderation_actions') rows = rows.map(({ actor_user_id, actor_subject_id, ...r }) => r); // who acted is staff's, not theirs++ for (const [name, text] of EXPORTS) {++ let rows = await d.prepare(text).all({ s: [subject], ids });++ if (name === 'chat_messages.json') rows = rows.map(db.chatMessageRow);++ if (name === 'moderation_on_you.json') rows = rows.map(({ actor_user_id, actor_subject_id, ...r }) => db.moderationRow(r)); // who acted is staff's, not theirsif (!rows.length) continue;if (rows.length > ROW_LIMIT) truncated.push(name);files.push({ name, content: rows.slice(0, ROW_LIMIT) });@@@ -91,61 +72,66 @@ function exportPart(d, subject) {// ── Deletion ───────────────────────────────────────────────────--function erase(d, subjects) {-- const ids = liveIds(d, subjects);++// [erased key, DELETE]: in this order, in one transaction.++const ERASE_FIRST = [++ ['messages', 'DELETE FROM chat_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids)'],++ ['room_messages', 'DELETE FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids)'],++ ['direct_messages', 'DELETE FROM dm_messages WHERE sender_subject_id = ANY(@s) OR sender_id = ANY(@ids)'],++ ['conversations_left', 'DELETE FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids)'],++ ['conversations', 'DELETE FROM dm_conversations WHERE id NOT IN (SELECT conversation_id FROM dm_participants)'],++ ['room_memberships', 'DELETE FROM room_members WHERE user_id = ANY(@ids)'],++ ['room_attachments', 'DELETE FROM room_attachments WHERE attached_by_subject = ANY(@s)'],++];++const ERASE_AFTER_ROOMS = [++ ['calls', 'DELETE FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids)'],++ ['blocks', 'DELETE FROM network_blocks WHERE blocker_subject = ANY(@s) OR blocked_subject = ANY(@s)'],++ ['blocks', 'DELETE FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) OR blocked_id = ANY(@ids)'],++ // A person's own summaries and timeline are keyed by their Live id (scope 'user').++ ['ai_summaries', "DELETE FROM chat_ai_summaries WHERE scope = 'user' AND subject_id = ANY(@ids)"],++ ['timeline', "DELETE FROM chat_timeline_events WHERE scope = 'user' AND subject_id = ANY(@ids)"],++ ['tags', 'DELETE FROM user_tags WHERE user_id = ANY(@ids)'],++ ['moderator_roles', 'DELETE FROM channel_moderators WHERE user_id = ANY(@ids)'],++ ['pending_messages', 'DELETE FROM pending_ip_messages WHERE user_id = ANY(@ids)'],++];++// Their channel (only when they have Live ids): emotes, sounds, moderators, stats, audio requests, the Live mirrors.++const ERASE_CHANNEL = [++ ['moderator_roles', 'DELETE FROM channel_moderators WHERE channel_id IN (SELECT id FROM ctx_channels WHERE user_id = ANY(@ids))'],++ [null, 'DELETE FROM channel_moderation_settings WHERE channel_id IN (SELECT id FROM ctx_channels WHERE user_id = ANY(@ids))'],++ ['emotes', 'DELETE FROM emotes WHERE user_id = ANY(@ids)'],++ ['sounds', 'DELETE FROM channel_sounds WHERE created_by_subject_id = ANY(@s) OR created_by = ANY(@ids)'],++ ['channel_stats', 'DELETE FROM stream_first_chats WHERE channel_user_id = ANY(@ids)'],++ ['audio_requests', 'DELETE FROM audio_requests WHERE channel_user_id = ANY(@ids)'],++ ['live_mirror', 'DELETE FROM ctx_channels WHERE user_id = ANY(@ids)'],++ ['live_mirror', 'DELETE FROM ctx_streams WHERE user_id = ANY(@ids)'],++ ['live_mirror', 'DELETE FROM ctx_managed_streams WHERE user_id = ANY(@ids)'],++];++++async function erase(d, subjects) {++ const ids = await liveIds(d, subjects);++ const p = { s: subjects, ids };const erased = {}; const retained = {};-- const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };-- const del = (key, table, sc, ic) => {-- const w = whereFor(d, table, sc, ic, subjects, ids);-- if (!w) return;-- add(erased, key, d.prepare(`DELETE FROM ${table} WHERE ${w[0]}`).run(...w[1]).changes);-- };-- d.transaction(() => {-- del('messages', 'chat_messages', ['subject_id'], ['user_id']);-- del('room_messages', 'room_messages', ['subject_id'], ['user_id']);-- del('direct_messages', 'dm_messages', ['sender_subject_id'], ['sender_id']);-- del('conversations_left', 'dm_participants', ['subject_id'], ['user_id']);-- if (has(d, 'dm_conversations', 'id')) add(erased, 'conversations', d.prepare('DELETE FROM dm_conversations WHERE id NOT IN (SELECT conversation_id FROM dm_participants)').run().changes);-- del('room_memberships', 'room_members', [], ['user_id']);-- del('room_attachments', 'room_attachments', ['attached_by_subject'], []);-- const owned = whereFor(d, 'rooms', ['owner_subject'], ['owner_id'], subjects, ids);-- if (owned) {-- for (const r of d.prepare(`SELECT id FROM rooms WHERE ${owned[0]}`).all(...owned[1])) {-- const others = has(d, 'room_members', 'room_id') && d.prepare('SELECT 1 FROM room_members WHERE room_id = ? LIMIT 1').get(r.id);-- if (others) { d.prepare('UPDATE rooms SET owner_subject = NULL WHERE id = ?').run(r.id); add(retained, 'rooms_without_owner', 1); }-- else {-- if (has(d, 'room_messages', 'room_id')) d.prepare('DELETE FROM room_messages WHERE room_id = ?').run(r.id);-- d.prepare('DELETE FROM rooms WHERE id = ?').run(r.id);-- add(erased, 'rooms', 1);-- }++ const add = (o, k, n) => { if (k && n) o[k] = (o[k] || 0) + n; };++ const del = async ([key, text]) => { const st = d.prepare(text); add(erased, key, (await (text.includes('@') ? st.run(p) : st.run())).changes); };++ await d.tx(async () => {++ for (const e of ERASE_FIRST) await del(e);++ for (const r of await d.prepare('SELECT id FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)').all(p)) {++ const others = await d.prepare('SELECT 1 FROM room_members WHERE room_id = ? LIMIT 1').get(r.id);++ if (others) { await d.prepare('UPDATE rooms SET owner_subject = NULL WHERE id = ?').run(r.id); add(retained, 'rooms_without_owner', 1); }++ else {++ await d.prepare('DELETE FROM room_messages WHERE room_id = ?').run(r.id);++ await d.prepare('DELETE FROM rooms WHERE id = ?').run(r.id);++ add(erased, 'rooms', 1);}}-- del('calls', 'calls', ['created_by_subject', 'target_subject'], ['created_by', 'target_user_id']);-- del('blocks', 'network_blocks', ['blocker_subject', 'blocked_subject'], []);-- del('blocks', 'dm_blocks', ['blocker_subject_id'], ['blocker_id', 'blocked_id']);-- del('ai_summaries', 'chat_ai_summaries', ['subject_id'], []);-- del('timeline', 'chat_timeline_events', ['subject_id'], []);-- del('tags', 'user_tags', [], ['user_id']);-- del('moderator_roles', 'channel_moderators', [], ['user_id']);-- del('pending_messages', 'pending_ip_messages', [], ['user_id']);++ for (const e of ERASE_AFTER_ROOMS) await del(e);if (ids.length) {-- // Their channel: emotes, sounds, moderators, stats, audio requests, then the Live mirrors.-- const channels = has(d, 'ctx_channels', 'user_id') ? d.prepare(`SELECT id FROM ctx_channels WHERE user_id IN ${inList(ids)}`).all(...ids).map((r) => r.id) : [];-- if (channels.length && has(d, 'channel_moderators', 'channel_id')) add(erased, 'moderator_roles', d.prepare(`DELETE FROM channel_moderators WHERE channel_id IN ${inList(channels)}`).run(...channels).changes);-- if (channels.length && has(d, 'channel_moderation_settings', 'channel_id')) d.prepare(`DELETE FROM channel_moderation_settings WHERE channel_id IN ${inList(channels)}`).run(...channels);-- del('emotes', 'emotes', [], ['user_id']);-- del('sounds', 'channel_sounds', ['created_by_subject_id'], ['created_by']);-- del('channel_stats', 'stream_first_chats', [], ['channel_user_id']);-- del('audio_requests', 'audio_requests', [], ['channel_user_id']);-- for (const t of ['ctx_channels', 'ctx_streams', 'ctx_managed_streams']) del('live_mirror', t, [], ['user_id']);-- const set = ['username', 'display_name', 'avatar_url', 'profile_color', 'ban_reason'].filter((c) => has(d, 'ctx_users', c));-- for (const id of ids) {-- d.prepare(`UPDATE ctx_users SET ${set.map((c) => `${c} = ${c === 'username' ? '?' : 'NULL'}`).join(', ')} WHERE id = ?`).run(`deleted-${id}`, id);-- }++ for (const e of ERASE_CHANNEL) await del(e);++ await d.prepare(`UPDATE ctx_users SET username = 'deleted-' || id, display_name = NULL, avatar_url = NULL, profile_color = NULL, ban_reason = NULL++ WHERE id = ANY(?)`).run(ids);}-- const mod = whereFor(d, 'moderation_actions', ['actor_subject_id'], ['target_user_id', 'actor_user_id'], subjects, ids);-- if (mod) add(retained, 'moderation_actions', d.prepare(`SELECT COUNT(*) AS n FROM moderation_actions WHERE ${mod[0]}`).get(...mod[1]).n);-- })();++ const n = await d.prepare('SELECT COUNT(*) AS n FROM moderation_actions WHERE actor_subject_id = ANY(@s) OR target_user_id = ANY(@ids) OR actor_user_id = ANY(@ids)').get(p);++ add(retained, 'moderation_actions', n.n);++ });return { erased, retained, ids };}@@@ -168,30 +154,30 @@ async function apply(ev, { send = networkCall, log = console, onErased = null }if (ev.source !== 'network') return 'ignored:source';const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};const d = db.getDb();-- ensureSchema(d);if (ev.event_type === 'network.account.export_requested') {if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';-- const seen = d.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);++ const seen = await d.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);if (seen && seen.sent_at) return 'unchanged';-- const part = exportPart(d, p.subject);++ const part = await exportPart(d, p.subject);const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });if (!res) throw new Error('Network unreachable');const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);if (!outcome) throw new Error(`export part refused: ${res.status}`);-- d.prepare('INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)')++ await d.prepare(`INSERT INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, subject = excluded.subject, outcome = excluded.outcome, sent_at = excluded.sent_at, applied_at = ov_now_iso()`).run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, files: part.files.length }), new Date().toISOString());return outcome;}if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';-- let rec = d.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);++ let rec = await d.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);let result = 'confirmed';if (!rec) {const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];-- const { ids, ...counts } = erase(d, subjects);-- d.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));++ const { ids, ...counts } = await erase(d, subjects);++ await d.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));log.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);if (onErased) { try { onErased(subjects, ids); } catch { /* caches refill anyway */ } }-- rec = d.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);++ rec = await d.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);result = 'erased';}if (rec.sent_at) return 'unchanged';@@@ -199,8 +185,11 @@ async function apply(ev, { send = networkCall, log = console, onErased = null }const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });if (!res) throw new Error('Network unreachable');if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);-- d.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);++ await d.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);return result;}++// The table is in migrations/0001_initial.sql; kept for callers.++function ensureSchema() {}++module.exports = { apply, exportPart, erase, ensureSchema, TOPICS };@@@ -26,9 +26,9 @@ const db = require('../db/database');const ctx = require('../live-context');const chatAi = require('../ai/chat-ai');--router.get('/global', (req, res) => {++router.get('/global', async (req, res) => {try {-- const insight = chatAi.getGlobalInsight();++ const insight = await chatAi.getGlobalInsight();res.json({ insight: insight || null });} catch (err) {res.status(500).json({ error: 'Failed to load global chat insight' });@@@ -36,11 +36,11 @@ router.get('/global', (req, res) => {});// Browsable/searchable global timeline. Query: before=<ms>, since=<ms>, q=<search>, limit=<n>.--router.get('/timeline', (req, res) => {++router.get('/timeline', async (req, res) => {try {const num = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; };const limit = Math.min(60, Math.max(1, num(req.query.limit) || 25));-- const events = db.getChatTimelineEvents({++ const events = await db.getChatTimelineEvents({scope: 'global', subjectId: 0,before: num(req.query.before), since: num(req.query.since),q: req.query.q || null, limit,@@@ -51,12 +51,12 @@ router.get('/timeline', (req, res) => {}});--router.get('/user/:id', (req, res) => {++router.get('/user/:id', async (req, res) => {try {const uid = parseInt(req.params.id, 10);if (!Number.isFinite(uid)) return res.status(400).json({ error: 'Invalid user id' });-- const user = ctx.getUserById(uid);-- const insight = chatAi.getUserInsight(uid);++ const user = await ctx.getUserById(uid);++ const insight = await chatAi.getUserInsight(uid);// `streamer` (who they are as a broadcaster: overview + stream memories) is Live's stream// feature, not chat; Chat answers null for it (see README, For Opus). The key is kept so// the response shape matches Live's.@@@ -71,11 +71,11 @@ router.get('/user/:id', (req, res) => {});// An anonymous chatter's insight, keyed by their stable anon_id ("anon<N>").--router.get('/anon/:anonId', (req, res) => {++router.get('/anon/:anonId', async (req, res) => {try {const anonId = String(req.params.anonId || '');if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });-- const insight = chatAi.getAnonInsight(anonId);++ const insight = await chatAi.getAnonInsight(anonId);res.json({ insight: insight || null, user: { anon_id: anonId, username: anonId } });} catch (err) {res.status(500).json({ error: 'Failed to load anon chat insight' });@@@ -83,11 +83,11 @@ router.get('/anon/:anonId', (req, res) => {});// A bridged external (relay) chatter's insight, keyed by platform + username.--router.get('/relay/:platform/:username', (req, res) => {++router.get('/relay/:platform/:username', async (req, res) => {try {-- const ru = db.getRelayUser(req.params.platform, req.params.username);++ const ru = await db.getRelayUser(req.params.platform, req.params.username);if (!ru) return res.json({ insight: null, user: null });-- const insight = chatAi.getRelayUserInsight(ru.id);++ const insight = await chatAi.getRelayUserInsight(ru.id);res.json({insight: insight || null,user: { platform: ru.platform, username: ru.display_name || ru.username, message_count: ru.message_count || 0, first_seen: ru.first_seen || null },@@@ -101,15 +101,15 @@ router.get('/relay/:platform/:username', (req, res) => {// overviews, VOD moments, combined overview, session titles) with the chatter insight; the streamer// half is Live's stream feature and Chat cannot build it (README, For Opus). Chat serves the chat// half with Live's exact keys so the endpoint and the simple consumer keep working.--router.get('/timeline/:username', (req, res) => {++router.get('/timeline/:username', async (req, res) => {try {const uname = String(req.params.username || '').trim();-- const user = ctx.getUserByUsername(uname);++ const user = await ctx.getUserByUsername(uname);if (!user) return res.status(404).json({ error: 'Channel not found' });const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 12));let chatInsight = null;-- try { chatInsight = chatAi.getUserInsight(user.id) || null; } catch { chatInsight = null; }++ try { chatInsight = await chatAi.getUserInsight(user.id) || null; } catch { chatInsight = null; }res.json({username: user.username,display_name: user.display_name || user.username,@@@ -49,7 +49,7 @@ async function playAlertSound(chatServer, streamerId, streamId, kind) {try {if (!chatServer || !chatServer.broadcastToChannelRoom) return { played: false, source };const channel = await ctx.ensureChannelForUser(streamerId);-- const row = channel ? db.getChannelModerationSettingsRow(channel.id) : null;++ const row = channel ? await db.getChannelModerationSettingsRow(channel.id) : null;if (!row) return { played: false, source };let disk, mime;if (kind === 'goal') {@@@ -61,7 +61,7 @@ async function playAlertSound(chatServer, streamerId, streamId, kind) {}const snd = readSound(disk, mime);if (!snd) return { played: false, source };-- chatServer.broadcastToChannelRoom(streamerId, streamId || null, { type: 'soundboard-audio', audio: snd.audio, mimeType: snd.mimeType, source });++ await chatServer.broadcastToChannelRoom(streamerId, streamId || null, { type: 'soundboard-audio', audio: snd.audio, mimeType: snd.mimeType, source });return { played: true, source };} catch (err) {console.warn(`[Alerts] play ${kind} for ${streamerId}: ${err.message}`);@@@ -107,17 +107,17 @@ function estimatePlayMs({ audio, mimeType, seconds, speed } = {}) {}// ── Store ───────────────────────────────────────────────────────────────────────────────────────--function getRequest(id) { return db.get('SELECT * FROM audio_requests WHERE id = ?', [id]) || null; }++async function getRequest(id) { return await db.get('SELECT * FROM audio_requests WHERE id = ?', [id]) || null; }/*** Move one request along the state machine. Returns true when it moved (the row was in a state* that allows `to`); false when something else moved it first.*/--function transition(id, to, { error = null, actor = null, durationMs = null, now = Date.now() } = {}) {++async function transition(id, to, { error = null, actor = null, durationMs = null, now = Date.now() } = {}) {const from = Object.keys(TRANSITIONS).filter((s) => TRANSITIONS[s].includes(to));if (!from.length) return false;const final = to !== 'playing';-- const r = db.run(++ const r = await db.run(`UPDATE audio_requests SET state = ?, error = COALESCE(?, error), actor = COALESCE(?, actor),duration_ms = COALESCE(?, duration_ms),started_at = CASE WHEN ? = 'playing' THEN ? ELSE started_at END,@@@ -135,7 +135,7 @@ const rooms = new Map(); // room → { busy, timer, playingId }let stopped = false;function roomState(room) {-- if (!rooms.has(room)) rooms.set(room, { busy: false, timer: null, playingId: null, holdUntil: 0, holdTimer: null });++ if (!rooms.has(room)) rooms.set(room, { busy: false, timer: null, playingId: null, makingId: null, holdUntil: 0, holdTimer: null });return rooms.get(room);}@@@ -145,7 +145,7 @@ function holdRoom(room, until) {if (st.holdUntil && st.holdUntil <= until) return;st.holdUntil = until;if (st.holdTimer) clearTimeout(st.holdTimer);-- st.holdTimer = setTimeout(() => { st.holdUntil = 0; st.holdTimer = null; pump(room); }, Math.max(0, until - Date.now()));++ st.holdTimer = setTimeout(() => { st.holdUntil = 0; st.holdTimer = null; pump(room).catch((err) => console.warn('[AudioQueue] pump:', err.message)); }, Math.max(0, until - Date.now()));if (st.holdTimer.unref) st.holdTimer.unref();}@@@ -180,31 +180,43 @@ function stop() {* Accept a request. Returns { id, queued: true } or { queued: false, reason } — `duplicate` (this* room already has this key), `full` (the room or this requester is at its limit).*/--function enqueue({ kind, streamId = null, channelUserId = null, requestedBy = null, identityKey = null, label = null, payload = {}, dedupeKey = null, maxRoom = null, maxPerRequester = null, now = Date.now() }) {++async function enqueue({ kind, streamId = null, channelUserId = null, requestedBy = null, identityKey = null, label = null, payload = {}, dedupeKey = null, maxRoom = null, maxPerRequester = null, now = Date.now() }) {if (!KINDS.includes(kind)) throw new Error(`audio-queue: unknown kind ${kind}`);const room = roomKey({ streamId, channelUserId });if (!room) return { queued: false, reason: 'no_room' };-- const result = db.transaction(() => {-- if (dedupeKey && db.get('SELECT 1 FROM audio_requests WHERE room = ? AND dedupe_key = ?', [room, dedupeKey])) return { queued: false, reason: 'duplicate' };++ // One transaction: the caps are counted in it, and the unique (room, dedupe_key) index decides a duplicate++ // (ON CONFLICT DO NOTHING: no row back), so two processes enqueueing the same key store one row.++ const result = await db.tx(async () => {++ if (dedupeKey && await db.get('SELECT 1 FROM audio_requests WHERE room = ? AND dedupe_key = ?', [room, dedupeKey])) return { queued: false, reason: 'duplicate' };if (maxRoom) {-- const n = db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing')", [room]).n;++ const n = (await db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing')", [room])).n;if (n >= maxRoom) return { queued: false, reason: 'full' };}if (maxPerRequester && identityKey) {-- const n = db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND identity_key = ? AND state IN ('queued', 'playing')", [room, identityKey]).n;++ const n = (await db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND identity_key = ? AND state IN ('queued', 'playing')", [room, identityKey])).n;if (n >= maxPerRequester) return { queued: false, reason: 'full' };}-- const r = db.run(++ const r = await db.run(`INSERT INTO audio_requests (room, stream_id, channel_user_id, kind, state, requested_by, identity_key, label, payload, dedupe_key, created_at)-- VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)`,++ VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)++ ON CONFLICT (room, dedupe_key) DO NOTHING`,[room, streamId || null, channelUserId || null, kind, requestedBy, identityKey, label ? String(label).slice(0, 300) : null, JSON.stringify(payload || {}), dedupeKey, now],);++ if (!r.changes) return { queued: false, reason: 'duplicate' };return { queued: true, id: Number(r.lastInsertRowid), room };});-- if (result.queued) setImmediate(() => pump(result.room));++ if (result.queued) setImmediate(() => { pump(result.room).catch((err) => console.warn('[AudioQueue] pump:', err.message)); });return result;}++/**++ * Take a queued request to play (plan T3 decision 3): one atomic UPDATE, so of two pumps (two processes) at most++ * one gets the row back; null = someone else took it, or it was skipped. Counts the attempt.++ */++async function claim(id) {++ return await db.get("UPDATE audio_requests SET state = 'playing', attempts = attempts + 1 WHERE id = ? AND state = 'queued' RETURNING *", [id]) || null;++}++/** Play the next request of a room if none is playing. */async function pump(room) {if (stopped || !room) return;@@@ -215,10 +227,14 @@ async function pump(room) {for (;;) {if (stopped) return;let row;-- try { row = db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1", [room]); } catch { return; }++ try { row = await db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1", [room]); } catch { return; }if (!row) return;-- if (row.attempts >= MAX_ATTEMPTS) { transition(row.id, 'failed', { error: 'gave up after repeated attempts', actor: 'system' }); continue; }-- db.run('UPDATE audio_requests SET attempts = attempts + 1 WHERE id = ?', [row.id]);++ if (row.attempts >= MAX_ATTEMPTS) { await transition(row.id, 'failed', { error: 'gave up after repeated attempts', actor: 'system' }); continue; }++ const claimed = await claim(row.id);++ if (!claimed) continue; // another pump took it (or it was skipped): the next one++ row = claimed;++ // The audio is made outside any transaction (a synth takes seconds); the row stays 'playing' meanwhile.++ st.makingId = row.id;const perform = performers[row.kind];let made = null, error = null;try {@@@ -226,14 +242,17 @@ async function pump(room) {if (!perform) error = `no performer for ${row.kind}`;else if (!made || !made.frame) error = 'no audio';} catch (err) { error = (err && err.message) || 'failed'; }++ st.makingId = null;if (stopped) return;-- if (error) { transition(row.id, 'failed', { error: String(error).slice(0, 300), actor: 'system' }); continue; }++ if (error) { await transition(row.id, 'failed', { error: String(error).slice(0, 300), actor: 'system' }); continue; }const durationMs = Math.round(Math.min(MAX_PLAY_MS, Math.max(MIN_PLAY_MS, Number(made.durationMs) || estimatePlayMs(made.frame))));// Skipped or cleared while it was being made: nothing goes out.-- if (!transition(row.id, 'playing', { durationMs })) continue;++ const now = Date.now();++ const started = await db.run("UPDATE audio_requests SET started_at = ?, duration_ms = ? WHERE id = ? AND state = 'playing'", [now, durationMs, row.id]);++ if (!started.changes) continue;try { if (deliver) deliver(row, { ...made.frame, request_id: row.id }); } catch (err) { console.warn('[AudioQueue] deliver:', err.message); }st.playingId = row.id;-- st.timer = setTimeout(() => finish(room, row.id, 'played'), durationMs + GAP_MS);++ st.timer = setTimeout(() => { finish(room, row.id, 'played').catch((err) => console.warn('[AudioQueue] finish:', err.message)); }, durationMs + GAP_MS);if (st.timer.unref) st.timer.unref();return;}@@@ -241,15 +260,15 @@ async function pump(room) {}/** End the playing request of a room (its window ended, it was skipped, or the client reported). */--function finish(room, id, to, opts = {}) {++async function finish(room, id, to, opts = {}) {const st = roomState(room);let moved = false;-- try { moved = transition(id, to, opts); } catch { moved = false; }++ try { moved = await transition(id, to, opts); } catch { moved = false; }if (st.playingId === id) {if (st.timer) clearTimeout(st.timer);st.timer = null;st.playingId = null;-- setImmediate(() => pump(room));++ setImmediate(() => { pump(room).catch((err) => console.warn('[AudioQueue] pump:', err.message)); });}return moved;}@@@ -258,25 +277,27 @@ function finish(room, id, to, opts = {}) {* Skip one request of a room: `id`, or else the one playing, or else the next queued.* Returns the skipped row or null.*/--function skip(room, { id = null, actor = null } = {}) {++async function skip(room, { id = null, actor = null } = {}) {let row = null;-- if (id) row = db.get("SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state IN ('queued', 'playing')", [id, room]);-- else row = db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1", [room])-- || db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1", [room]);++ if (id) row = await db.get("SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state IN ('queued', 'playing')", [id, room]);++ else row = await db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1", [room])++ || await db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1", [room]);if (!row) return null;-- const moved = row.state === 'playing' ? finish(room, row.id, 'skipped', { actor }) : transition(row.id, 'skipped', { actor });++ // 'playing' and out (the room's clip) is finished; 'playing' still being made never went out: no skip frame.++ const out = row.state === 'playing' && roomState(room).playingId === row.id;++ const moved = out ? await finish(room, row.id, 'skipped', { actor }) : await transition(row.id, 'skipped', { actor });if (!moved) return null;-- if (row.state === 'playing' && deliver) { try { deliver(row, { type: 'audio-skip', request_id: row.id, kind: row.kind }); } catch { /* */ } }++ if (out && deliver) { try { deliver(row, { type: 'audio-skip', request_id: row.id, kind: row.kind }); } catch { /* */ } }return { ...row, state: 'skipped' };}/** Skip everything queued or playing in a room. Returns the ids skipped. */--function clear(room, { actor = null } = {}) {-- const rows = db.all("SELECT * FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing') ORDER BY id", [room]);++async function clear(room, { actor = null } = {}) {++ const rows = await db.all("SELECT * FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing') ORDER BY id", [room]);const ids = [];let sample = null;for (const row of rows) {-- const moved = row.state === 'playing' ? finish(room, row.id, 'skipped', { actor }) : transition(row.id, 'skipped', { actor });++ const moved = row.state === 'playing' ? await finish(room, row.id, 'skipped', { actor }) : await transition(row.id, 'skipped', { actor });if (moved) { ids.push(row.id); sample = sample || row; }}if (ids.length && deliver) { try { deliver(sample, { type: 'audio-clear', request_ids: ids }); } catch { /* */ } }@@@ -284,23 +305,23 @@ function clear(room, { actor = null } = {}) {}/** The playing client's report: its clip ended ('played') or could not play ('failed'). */--function report(room, id, state, { error = null, actor = null } = {}) {++async function report(room, id, state, { error = null, actor = null } = {}) {if (state !== 'played' && state !== 'failed') return false;-- const row = db.get("SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state = 'playing'", [id, room]);++ const row = await db.get("SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state = 'playing'", [id, room]);if (!row) return false;-- return finish(room, row.id, state, { error: state === 'failed' ? String(error || 'playback failed').slice(0, 300) : null, actor });++ return await finish(room, row.id, state, { error: state === 'failed' ? String(error || 'playback failed').slice(0, 300) : null, actor });}/** A room's queue: what plays now, what waits, and the latest finished requests. */--function list(room, { recent = 20 } = {}) {++async function list(room, { recent = 20 } = {}) {const shape = (r) => ({id: r.id, kind: r.kind, state: r.state, requested_by: r.requested_by, label: r.label,error: r.error, actor: r.actor, duration_ms: r.duration_ms,created_at: r.created_at, started_at: r.started_at, finished_at: r.finished_at,});-- const playing = db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1", [room]);-- const queued = db.all("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id", [room]);-- const done = db.all("SELECT * FROM audio_requests WHERE room = ? AND state IN ('played', 'skipped', 'failed') ORDER BY COALESCE(finished_at, created_at) DESC, id DESC LIMIT ?", [room, Math.min(Math.max(parseInt(recent, 10) || 0, 0), 100)]);++ const playing = await db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1", [room]);++ const queued = await db.all("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id", [room]);++ const done = await db.all("SELECT * FROM audio_requests WHERE room = ? AND state IN ('played', 'skipped', 'failed') ORDER BY COALESCE(finished_at, created_at) DESC, id DESC LIMIT ?", [room, Math.min(Math.max(parseInt(recent, 10) || 0, 0), 100)]);return { room, playing: playing ? shape(playing) : null, queued: queued.map(shape), recent: done.map(shape) };}@@@ -308,11 +329,11 @@ function list(room, { recent = 20 } = {}) {* Boot: rows playing when Chat stopped were delivered, so they are played; rows queued too long* ago fail as expired; every room with queued rows starts playing again.*/--function recover({ now = Date.now(), graceMs = RESUME_GRACE_MS } = {}) {-- const played = db.run("UPDATE audio_requests SET state = 'played', finished_at = ?, error = 'delivered before a restart' WHERE state = 'playing'", [now]).changes;-- const expired = db.run("UPDATE audio_requests SET state = 'failed', finished_at = ?, error = 'expired', actor = 'system' WHERE state = 'queued' AND created_at < ?", [now, now - STALE_MS]).changes;-- db.run("DELETE FROM audio_requests WHERE state IN ('played', 'skipped', 'failed') AND COALESCE(finished_at, created_at) < ?", [now - KEEP_MS]);-- const pending = db.all("SELECT DISTINCT room FROM audio_requests WHERE state = 'queued'").map((r) => r.room);++async function recover({ now = Date.now(), graceMs = RESUME_GRACE_MS } = {}) {++ const played = (await db.run("UPDATE audio_requests SET state = 'played', finished_at = ?, error = 'delivered before a restart' WHERE state = 'playing'", [now])).changes;++ const expired = (await db.run("UPDATE audio_requests SET state = 'failed', finished_at = ?, error = 'expired', actor = 'system' WHERE state = 'queued' AND created_at < ?", [now, now - STALE_MS])).changes;++ await db.run("DELETE FROM audio_requests WHERE state IN ('played', 'skipped', 'failed') AND COALESCE(finished_at, created_at) < ?", [now - KEEP_MS]);++ const pending = (await db.all("SELECT DISTINCT room FROM audio_requests WHERE state = 'queued'")).map((r) => r.room);for (const room of pending) holdRoom(room, now + graceMs);if (played || expired || pending.length) console.log(`[AudioQueue] recovered: ${played} finished, ${expired} expired, ${pending.length} room(s) resuming`);return { played, expired, rooms: pending };@@@ -321,5 +342,5 @@ function recover({ now = Date.now(), graceMs = RESUME_GRACE_MS } = {}) {module.exports = {STATES, TRANSITIONS, KINDS, STALE_MS, GAP_MS, RESUME_GRACE_MS, RESUME_SETTLE_MS,canTransition, roomKey, estimatePlayMs, mp3Bitrate,-- init, stop, enqueue, pump, skip, clear, report, list, recover, roomJoined, getRequest, transition,++ init, stop, enqueue, pump, claim, skip, clear, report, list, recover, roomJoined, getRequest, transition,};@@@ -41,12 +41,12 @@ function canAssignMods(user, channel) {}/** Middleware: require channel access (owner, channel mod, or staff). */--function requireChannelAccess(req, res, next) {++async function requireChannelAccess(req, res, next) {const channelId = parseInt(req.params.channelId, 10);if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });-- const channel = ctx.getChannelById(channelId);++ const channel = await ctx.getChannelById(channelId);if (!channel) return res.status(404).json({ error: 'Channel not found' });-- if (!permissions.canModerateChannel(req.user, channelId)) {++ if (!await permissions.canModerateChannel(req.user, channelId)) {return res.status(403).json({ error: 'Channel moderation access required' });}req.channel = channel;@@@ -57,7 +57,7 @@ function requireChannelAccess(req, res, next) {router.get('/moderation/mine', requireAuth, async (req, res) => {try {const owned = await ctx.ensureChannelForUser(req.user.id);-- const moderated = db.getChannelsByModerator(req.user.id) || [];++ const moderated = await db.getChannelsByModerator(req.user.id) || [];const channelMap = new Map();if (owned) channelMap.set(owned.id, owned);@@@ -66,11 +66,11 @@ router.get('/moderation/mine', requireAuth, async (req, res) => {if (!channelMap.has(id)) channelMap.set(id, { id, user_id: ch.owner_user_id ?? ch.user_id, title: ch.title });}-- const channels = [...channelMap.values()].map((channel) => ({++ const channels = (await Promise.all([...channelMap.values()].map(async (channel) => ({...channel,-- moderation_settings: ctx.getChannelModerationSettings(channel.id),-- moderators: db.getChannelModerators(channel.id),-- }));++ moderation_settings: await ctx.getChannelModerationSettings(channel.id),++ moderators: await db.getChannelModerators(channel.id),++ }))));res.json({ channels });} catch (err) {@@@ -80,12 +80,12 @@ router.get('/moderation/mine', requireAuth, async (req, res) => {});// ── List channel mods ────────────────────────────────────────--router.get('/:channelId/mods', requireAuth, (req, res) => {++router.get('/:channelId/mods', requireAuth, async (req, res) => {try {const channelId = parseInt(req.params.channelId);-- const channel = ctx.getChannelById(channelId);++ const channel = await ctx.getChannelById(channelId);if (!channel) return res.status(404).json({ error: 'Channel not found' });-- res.json({ moderators: db.getChannelModerators(channelId), channel_id: channelId });++ res.json({ moderators: await db.getChannelModerators(channelId), channel_id: channelId });} catch (err) {res.status(500).json({ error: 'Failed to list channel moderators' });}@@@ -95,7 +95,7 @@ router.get('/:channelId/mods', requireAuth, (req, res) => {router.post('/:channelId/mods', requireAuth, async (req, res) => {try {const channelId = parseInt(req.params.channelId);-- const channel = ctx.getChannelById(channelId);++ const channel = await ctx.getChannelById(channelId);if (!channel) return res.status(404).json({ error: 'Channel not found' });if (!canAssignMods(req.user, channel)) {return res.status(403).json({ error: 'Only the channel owner or an admin can add moderators' });@@@ -109,8 +109,8 @@ router.post('/:channelId/mods', requireAuth, async (req, res) => {return res.status(400).json({ error: 'Channel owner is already a moderator' });}-- db.addChannelModerator(channelId, targetUser.id, req.user.id);-- db.logModerationAction({++ await db.addChannelModerator(channelId, targetUser.id, req.user.id);++ await db.logModerationAction({scope_type: 'channel',scope_id: channelId,actor_user_id: req.user.id,@@@ -118,7 +118,7 @@ router.post('/:channelId/mods', requireAuth, async (req, res) => {action_type: 'channel_mod_add',details: { channel_id: channelId, username: targetUser.username },});-- res.json({ message: `${targetUser.username} added as channel moderator`, moderators: db.getChannelModerators(channelId) });++ res.json({ message: `${targetUser.username} added as channel moderator`, moderators: await db.getChannelModerators(channelId) });} catch (err) {res.status(500).json({ error: 'Failed to add channel moderator' });}@@@ -129,14 +129,14 @@ router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {try {const channelId = parseInt(req.params.channelId);const userId = parseInt(req.params.userId);-- const channel = ctx.getChannelById(channelId);++ const channel = await ctx.getChannelById(channelId);if (!channel) return res.status(404).json({ error: 'Channel not found' });if (!canAssignMods(req.user, channel)) {return res.status(403).json({ error: 'Only the channel owner or an admin can remove moderators' });}-- const target = ctx.getUserById(userId);-- db.removeChannelModerator(channelId, userId);-- db.logModerationAction({++ const target = await ctx.getUserById(userId);++ await db.removeChannelModerator(channelId, userId);++ await db.logModerationAction({scope_type: 'channel',scope_id: channelId,actor_user_id: req.user.id,@@@ -144,16 +144,16 @@ router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {action_type: 'channel_mod_remove',details: { channel_id: channelId, username: (target && target.username) || String(userId) },});-- res.json({ message: 'Channel moderator removed', moderators: db.getChannelModerators(channelId) });++ res.json({ message: 'Channel moderator removed', moderators: await db.getChannelModerators(channelId) });} catch (err) {res.status(500).json({ error: 'Failed to remove channel moderator' });}});// ── Get channel moderation settings ──────────────────────────--router.get('/:channelId/moderation', requireAuth, requireChannelAccess, (req, res) => {++router.get('/:channelId/moderation', requireAuth, requireChannelAccess, async (req, res) => {try {-- res.json({ settings: ctx.getChannelModerationSettings(req.channel.id) });++ res.json({ settings: await ctx.getChannelModerationSettings(req.channel.id) });} catch (err) {res.status(500).json({ error: 'Failed to get moderation settings' });}@@@ -165,7 +165,7 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (rtry {const isOwnerOrStaff = req.channel.user_id === req.user.id || permissions.isGlobalModOrAbove(req.user);if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];-- const settings = db.upsertChannelModerationSettings(req.channel.id, {++ const settings = await db.upsertChannelModerationSettings(req.channel.id, {slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,followers_only: req.body.followers_only !== undefined ? parseBoolean(req.body.followers_only, false) : undefined,@@@ -225,7 +225,7 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (rsound_max_pitch_cents: req.body.sound_max_pitch_cents !== undefined ? Math.max(0, Math.min(2400, parseInt(req.body.sound_max_pitch_cents) || 1200)) : undefined,});-- db.logModerationAction({++ await db.logModerationAction({scope_type: 'channel',scope_id: req.channel.id,actor_user_id: req.user.id,@@@ -240,40 +240,40 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r});// ── View moderation action log ───────────────────────────────--router.get('/:channelId/moderation/logs', requireAuth, requireChannelAccess, (req, res) => {++router.get('/:channelId/moderation/logs', requireAuth, requireChannelAccess, async (req, res) => {try {const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);-- res.json({ actions: db.getModerationActions({ scopeType: 'channel', scopeId: req.channel.id, limit, offset }) });++ res.json({ actions: await db.getModerationActions({ scopeType: 'channel', scopeId: req.channel.id, limit, offset }) });} catch (err) {res.status(500).json({ error: 'Failed to load channel moderation log' });}});// ── Search channel chat messages ─────────────────────────────--router.get('/:channelId/moderation/chat-search', requireAuth, requireChannelAccess, (req, res) => {++router.get('/:channelId/moderation/chat-search', requireAuth, requireChannelAccess, async (req, res) => {try {const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);const q = (req.query.q || '').trim();const userId = req.query.user_id ? parseInt(req.query.user_id, 10) : null;-- res.json(db.searchChannelChatMessages(req.channel, { query: q, userId, limit, offset }));++ res.json(await db.searchChannelChatMessages(req.channel, { query: q, userId, limit, offset }));} catch (err) {res.status(500).json({ error: 'Failed to search channel chat' });}});// ── Delete a channel chat message ────────────────────────────--router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, requireChannelAccess, (req, res) => {++router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, requireChannelAccess, async (req, res) => {try {const messageId = parseInt(req.params.messageId, 10);-- const message = db.getChatMessageById(messageId);++ const message = await db.getChatMessageById(messageId);if (!message) return res.status(404).json({ error: 'Message not found' });if (!permissions.isGlobalModOrAbove(req.user)) {let inScope = false;if (message.stream_id) {-- const stream = ctx.getStreamById(message.stream_id);++ const stream = await ctx.getStreamById(message.stream_id);inScope = !!(stream && (stream.channel_id === req.channel.id || (!stream.channel_id && stream.user_id === req.channel.user_id)));} else if (message.channel_user_id) {inScope = message.channel_user_id === req.channel.user_id;@@@ -281,19 +281,19 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, reif (!inScope) return res.status(403).json({ error: 'Message is outside this channel scope' });}-- db.deleteChatMessage(messageId, req.user.id);++ await db.deleteChatMessage(messageId, req.user.id);const delPayload = { type: 'delete-messages', ids: [messageId] };-- const chanUid = message.channel_user_id || (message.stream_id ? (ctx.getStreamById(message.stream_id) || {}).user_id || null : null);++ const chanUid = message.channel_user_id || (message.stream_id ? (await ctx.getStreamById(message.stream_id) || {}).user_id || null : null);try {const chatServer = require('./chat-server');-- if (chanUid || message.stream_id) chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);-- if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);-- else if (chanUid) chatServer.forwardToGlobalByChannel(chanUid, delPayload);-- else chatServer.broadcastGlobal(delPayload);++ if (chanUid || message.stream_id) await chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);++ if (message.stream_id) await chatServer.forwardToGlobal(message.stream_id, delPayload);++ else if (chanUid) await chatServer.forwardToGlobalByChannel(chanUid, delPayload);++ else await chatServer.broadcastGlobal(delPayload);} catch { /* chat server may not be initialized */ }-- db.logModerationAction({++ await db.logModerationAction({scope_type: 'channel',scope_id: req.channel.id,actor_user_id: req.user.id,@@@ -91,7 +91,7 @@ class ChatServer {this._modeWrites = new Map(); // channelId → /slow or /subonly writes running/** @type {WeakMap<object, Set<string>|null>} frame → the subjects who blocked its author (_blockersOf) */this._blockMemo = new WeakMap();-- ctx.onChannelSettings((channelId, settings) => this._channelSettingsSeen(channelId, settings));++ ctx.onChannelSettings(async (channelId, settings) => await this._channelSettingsSeen(channelId, settings));this.heartbeatInterval = null;// TTS and sound requests queue in Chat's database (./audio-queue.js), one room at a time./** @type {Map<string, number>} `${streamId}:${userKey}` → last soundboard trigger */@@@ -180,7 +180,7 @@ class ChatServer {/*** Attach to an existing HTTP server for WebSocket upgrade*/-- init(server) {++ async init(server) {this.wss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });// Word filter@@@ -209,15 +209,15 @@ class ChatServer {// ── Viewer snapshot recording (every 60s) ────────────if (this._snapshotInterval) clearInterval(this._snapshotInterval);-- this._snapshotInterval = setInterval(() => {-- this._recordViewerSnapshots();++ this._snapshotInterval = setInterval(async () => {++ await this._recordViewerSnapshots();}, 60_000);if (this._autoDeleteSweepInterval) clearInterval(this._autoDeleteSweepInterval);-- this._autoDeleteSweepInterval = setInterval(() => {-- this._sweepExpiredChatMessages();++ this._autoDeleteSweepInterval = setInterval(async () => {++ await this._sweepExpiredChatMessages();}, CHAT_AUTO_DELETE_SWEEP_MS);-- this._sweepExpiredChatMessages();++ await this._sweepExpiredChatMessages();this.wss.on('connection', (ws, req) => {this.handleConnection(ws, req);@@@ -227,13 +227,13 @@ class ChatServer {// where its frame goes. recover() resumes what a restart interrupted.audioQueue.init({performers: {-- tts: (row, p) => this._makeTtsAudio(p),-- 'channel-sound': (row, p) => this._makeChannelSoundAudio(p),-- soundboard: (row, p) => this._makeSoundboardAudio(p),++ tts: async (row, p) => await this._makeTtsAudio(p),++ 'channel-sound': async (row, p) => await this._makeChannelSoundAudio(p),++ soundboard: async (row, p) => await this._makeSoundboardAudio(p),},-- deliver: (row, frame) => this._broadcastTtsPayload(row.stream_id, row.channel_user_id, frame),++ deliver: async (row, frame) => await this._broadcastTtsPayload(row.stream_id, row.channel_user_id, frame),});-- try { audioQueue.recover(); } catch (err) { console.warn('[AudioQueue] recover failed:', err.message); }++ try { await audioQueue.recover(); } catch (err) { console.warn('[AudioQueue] recover failed:', err.message); }console.log('[Chat] WebSocket chat server initialized');return this.wss;@@@ -358,7 +358,7 @@ class ChatServer {console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);return;}-- chain = chain.then(() => this.handleMessage(ws, msg)).catch((err) => {++ chain = chain.then(async () => await this.handleMessage(ws, msg)).catch((err) => {console.warn('[Chat] message handling failed:', err.message);});};@@@ -393,11 +393,11 @@ class ChatServer {const lastMsg = this.rateLimits.get(rateKey) || 0;// Slow mode of the stream that governs the room (an offline/channel-room join has no// stream id of its own but posts into the live room).-- const slowStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);-- const streamSlowMs = this.slowModeMs(slowStreamId);++ const slowStreamId = client.streamId || (client.channelUserId ? await this._moderationStreamFor(client) : null);++ const streamSlowMs = await this.slowModeMs(slowStreamId);// The room's moderators (broadcaster, channel mods, chat staff) are not slowed: they keep// the flood limit only, so they can moderate and turn slow mode off again at once.-- const slowExempt = streamSlowMs > 0 && !!client.user && permissions.canModerateStream(client.user, slowStreamId);++ const slowExempt = streamSlowMs > 0 && !!client.user && await permissions.canModerateStream(client.user, slowStreamId);const effectiveLimit = slowExempt ? this.DEFAULT_RATE_LIMIT_MS : Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);if (now - lastMsg < effectiveLimit) {this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });@@@ -409,10 +409,10 @@ class ChatServer {switch (msg.type) {case 'chat':await this._warmSubOnly(client).catch(() => {});-- this.handleChatMessage(ws, client, msg);++ await this.handleChatMessage(ws, client, msg);break;case 'self-delete-history':-- this.handleSelfDeleteHistory(ws, client);++ await this.handleSelfDeleteHistory(ws, client);break;case 'join':case 'join_stream': {@@@ -422,7 +422,7 @@ class ChatServer {if (user) {if (!client.user || client.user.id === user.id) {client.user = user;-- this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list++ await this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user listclient.tokenIat = session.tokenIat(msg.token);client.anonId = null; // no longer anonymous} else {@@@ -438,7 +438,7 @@ class ChatServer {await ctx.warm({ user: client.user, streamId: nextStreamId, channelUserId: requestedChannel, ip: client.ip }).catch(() => {});if (!nextStreamId && requestedChannel) {// Offline channel room: its moderation comes from the channel's latest stream.-- await ctx.warm({ user: null, streamId: ctx.latestStreamIdForUser(requestedChannel), channelUserId: requestedChannel }).catch(() => {});++ await ctx.warm({ user: null, streamId: await ctx.latestStreamIdForUser(requestedChannel), channelUserId: requestedChannel }).catch(() => {});}client.streamId = nextStreamId;// Channel room: the streamer's stable user id. Lets a viewer stay in@@@ -451,12 +451,12 @@ class ChatServer {// room, where B's bans and chat rules (checked against the stream) never applied.if (client.streamId) {channelUserId = null;-- try { const s = ctx.getStreamById(client.streamId); if (s) channelUserId = s.user_id; } catch { /* ignore */ }++ try { const s = await ctx.getStreamById(client.streamId); if (s) channelUserId = s.user_id; } catch { /* ignore */ }}client.channelUserId = channelUserId;client._modStream = null;// Look up this member's VIP badge for the room now, so their first message has it.-- if (client.user && channelUserId) { try { vipBadges.warm(this._subjectOfUser(client.user), db.subjectFor(channelUserId)); } catch { /* */ } }++ if (client.user && channelUserId) { try { vipBadges.warm(await this._subjectOfUser(client.user), await db.subjectFor(channelUserId)); } catch { /* */ } }// A TTS/sound queue held since a restart plays once its room has listeners again.audioQueue.roomJoined(audioQueue.roomKey({ streamId: client.streamId, channelUserId: client.streamId ? null : channelUserId }));// Update viewer counts for old and new streams@@@ -468,9 +468,9 @@ class ChatServer {setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);// Send identity confirmation so the client knows who it isconst displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;-- const roomStreamId = client.streamId || this._moderationStreamFor(client);-- const streamSlowSec = Math.round(this.slowModeMs(roomStreamId) / 1000);-- const streamSettings = this._getChannelChatSettings(client.streamId);++ const roomStreamId = client.streamId || await this._moderationStreamFor(client);++ const streamSlowSec = Math.round(await this.slowModeMs(roomStreamId) / 1000);++ const streamSettings = await this._getChannelChatSettings(client.streamId);this.sendTo(ws, {type: 'auth',authenticated: !!client.user,@@@ -480,7 +480,7 @@ class ChatServer {role: client.user ? client.user.role : 'anon',user_id: client.user?.id || null,slowmode_seconds: streamSlowSec,-- sub_only: !!(roomStreamId && this._getChannelChatSettings(roomStreamId).sub_only),++ sub_only: !!(roomStreamId && (await this._getChannelChatSettings(roomStreamId)).sub_only),allow_auto_delete: !client.streamId || streamSettings.viewer_auto_delete_enabled !== 0,allow_self_delete_all: !client.streamId || streamSettings.viewer_delete_all_enabled !== 0,gifs_enabled: !client.streamId || streamSettings.gifs_enabled !== 0,@@@ -501,7 +501,7 @@ class ChatServer {min_auto_delete_minutes: MIN_CHAT_AUTO_DELETE_MINUTES,// Language the channel lives in — the client shows "auto-translated for the// streamer" when it isn't English (see server/i18n/translate.js).-- channel_language: this._channelLanguage(client.channelUserId),++ channel_language: await this._channelLanguage(client.channelUserId),});break;}@@@ -511,8 +511,8 @@ class ChatServer {// Chat rooms (server/rooms/, openvibe.chat): a socket follows one room at a time.case 'join_room': {const rooms = require('../rooms/rooms');-- const room = rooms.bySlug(msg.room);-- const a = room ? rooms.access(room, client.user) : null;++ const room = await rooms.bySlug(msg.room);++ const a = room ? await rooms.access(room, client.user) : null;if (!room || !a.read) { this.sendTo(ws, { type: 'room_error', room: msg.room || null, code: 'rooms.not_found', message: 'No such room' }); break; }client.roomId = room.id;client.roomSlug = room.slug;@@@ -525,10 +525,10 @@ class ChatServer {break;case 'room_message': {const rooms = require('../rooms/rooms');-- const room = client.roomId ? rooms.bySlug(client.roomSlug) : null;++ const room = client.roomId ? await rooms.bySlug(client.roomSlug) : null;if (!room || room.id !== client.roomId) { this.sendTo(ws, { type: 'room_error', code: 'rooms.not_joined', message: 'Open a room first' }); break; }try {-- const message = rooms.post(room, client.user, msg.message);++ const message = await rooms.post(room, client.user, msg.message);this.broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });} catch (err) {this.sendTo(ws, { type: 'room_error', room: room.slug, code: err.code || 'rooms.error', message: err.code ? err.message : 'Your message could not be sent' });@@@ -536,7 +536,7 @@ class ChatServer {break;}case 'get-users':-- this.sendTo(ws, { type: 'users-list', users: this.getUserList(client.streamId) });++ this.sendTo(ws, { type: 'users-list', users: await this.getUserList(client.streamId) });break;default:break;@@@ -544,21 +544,21 @@ class ChatServer {}/** The stream whose bans and chat settings govern an offline channel-room chatter (cached 30s). */-- _moderationStreamFor(client) {++ async _moderationStreamFor(client) {if (!client || !client.channelUserId) return null;const now = Date.now();if (client._modStream && now - client._modStream.at < 30000) return client._modStream.id;let id = null;-- try { id = ctx.latestStreamIdForUser(client.channelUserId); } catch { id = null; }++ try { id = await ctx.latestStreamIdForUser(client.channelUserId); } catch { id = null; }client._modStream = { id, at: now };return id;}-- handleSelfDeleteHistory(ws, client) {++ async handleSelfDeleteHistory(ws, client) {if (!client) return;-- const canBypass = client.streamId ? permissions.canModerateStream(client.user, client.streamId) : false;-- const chatSettings = this._getChannelChatSettings(client.streamId);++ const canBypass = client.streamId ? await permissions.canModerateStream(client.user, client.streamId) : false;++ const chatSettings = await this._getChannelChatSettings(client.streamId);if (client.streamId && !canBypass && chatSettings.viewer_delete_all_enabled === 0) {this.sendTo(ws, { type: 'error', message: 'This streamer has disabled viewer self-delete for this chat.' });return;@@@ -566,12 +566,12 @@ class ChatServer {let ids = [];if (client.user?.id) {-- ids = db.deleteUserChatMessages(client.user.id, {++ ids = await db.deleteUserChatMessages(client.user.id, {streamId: client.streamId || null,deletedBy: client.user.id,});} else if (client.anonId) {-- ids = db.deleteAnonChatMessages(client.anonId, {++ ids = await db.deleteAnonChatMessages(client.anonId, {streamId: client.streamId || null,deletedBy: null,});@@@ -580,10 +580,10 @@ class ChatServer {return;}-- this._broadcastDeletedMessages(client.streamId || null, ids);++ await this._broadcastDeletedMessages(client.streamId || null, ids);try {-- db.logModerationAction({++ await db.logModerationAction({scope_type: client.streamId ? 'stream' : 'site',scope_id: client.streamId || undefined,actor_user_id: client.user?.id || undefined,@@@ -607,7 +607,7 @@ class ChatServer {* Build a deduplicated list of users in a given stream (or global if null).* Returns { logged: [{username, display_name, avatar_url, role}], anonCount: N }*/-- getUserList(streamId) {++ async getUserList(streamId) {const seen = new Set();const logged = [];let anonCount = 0, hiddenCount = 0;@@@ -617,7 +617,7 @@ class ChatServer {if (seen.has(c.user.id)) continue;seen.add(c.user.id);// chat.presence_prefs: someone who turned off "show my name in user lists" is counted, not named.-- if (this._hiddenFromUserList(c.user)) { hiddenCount++; continue; }++ if (await this._hiddenFromUserList(c.user)) { hiddenCount++; continue; }logged.push({username: c.user.username,display_name: c.user.display_name || c.user.username,@@@ -641,8 +641,8 @@ class ChatServer {* chat.presence_prefs). Synchronous: the cached record, fetched at join; not cached yet → fetch it* in the background and name them until it arrives.*/-- _hiddenFromUserList(user) {-- const subject = this._subjectOfUser(user);++ async _hiddenFromUserList(user) {++ const subject = await this._subjectOfUser(user);if (!subject) return false;const { presence } = require('../prefs/stores');const cached = presence.peek(subject);@@@ -667,7 +667,7 @@ class ChatServer {const user = connected.get(String(name).toLowerCase());let state = 'offline';if (user) {-- const subject = this._subjectOfUser(user);++ const subject = await this._subjectOfUser(user);if (!subject) state = 'online';else {let prefs = presence.peek(subject);@@@ -687,9 +687,9 @@ class ChatServer {presenceChanged(subject) {if (!subject) return;const { presence } = require('../prefs/stores');-- presence.get(subject).catch(() => {}).then(() => {++ presence.get(subject).catch(() => {}).then(async () => {const streams = new Set();-- for (const [, c] of this.clients) if (c.user && this._subjectOfUser(c.user) === subject) streams.add(c.streamId ?? null);++ for (const [, c] of this.clients) if (c.user && await this._subjectOfUser(c.user) === subject) streams.add(c.streamId ?? null);for (const sid of streams) this.broadcastUsersList(sid);});}@@@ -722,7 +722,7 @@ class ChatServer {/*** Handle a chat message*/-- handleChatMessage(ws, client, msg) {++ async handleChatMessage(ws, client, msg) {let text = (msg.message || '').trim();// Absolute upper bound (DoS guard) = the highest value any channel/admin can configure// (admins can set a channel up to 6000). The REAL per-channel limit is enforced below at@@@ -738,7 +738,7 @@ class ChatServer {// no input of any kind.// Offline channel chat has no stream of its own; bans and chat rules come from the channel's// most recent stream, so a ban does not stop at the end of a broadcast.-- const modStreamId = client.streamId || this._moderationStreamFor(client);++ const modStreamId = client.streamId || await this._moderationStreamFor(client);if (client.user && ctx.isUserBanned(client.user.id, modStreamId)) {this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });return;@@@ -750,13 +750,13 @@ class ChatServer {// ── Stream utility commands (!sr, !queue, !nowplaying, !skip) ──if (text.startsWith('!')) {-- this.handleBangCommand(ws, client, text);++ await this.handleBangCommand(ws, client, text);return;}// ── Chat commands ────────────────────────────────────if (text.startsWith('/')) {-- this.handleCommand(ws, client, text);++ await this.handleCommand(ws, client, text);return;}@@@ -765,17 +765,17 @@ class ChatServer {// stream id still posts into the live room (broadcastToChannelRoom).if (modStreamId && client.ip) {try {-- const stream = ctx.getStreamById(modStreamId);-- const channel = stream?.channel_id ? ctx.getChannelById(stream.channel_id) : (stream ? ctx.getChannelByUserId(stream.user_id) : null);++ const stream = await ctx.getStreamById(modStreamId);++ const channel = stream?.channel_id ? await ctx.getChannelById(stream.channel_id) : (stream ? await ctx.getChannelByUserId(stream.user_id) : null);if (channel) {-- const settings = ctx.getChannelModerationSettings(channel.id);++ const settings = await ctx.getChannelModerationSettings(channel.id);if (settings?.ip_approval_mode) {const isStaffBypass = permissions.can(client.user, 'staff.moderation.bypass');const isOwner = client.user && stream && stream.user_id === client.user.id;if (!isStaffBypass && !isOwner) {if (!ctx.isIpApproved(channel.id, client.ip)) {// Auto-approve IPs that have existing non-deleted chat messages in this channel's streams-- const existing = db.get(++ const existing = await db.get(`SELECT 1 FROM chat_messages cmJOIN ctx_streams s ON cm.stream_id = s.idWHERE s.channel_id = ? AND cm.is_deleted = 0@@@ -789,7 +789,7 @@ class ChatServer {} else {// Hold the message for streamer approvalconst username = client.user ? client.user.display_name : client.anonId;-- db.holdMessageForApproval({++ await db.holdMessageForApproval({channelId: channel.id,streamId: modStreamId,ip: client.ip,@@@ -814,7 +814,7 @@ class ChatServer {}// ── Channel moderation settings ──────────────────────-- if (this._chatRulesBlock(ws, client, text, modStreamId)) return;++ if (await this._chatRulesBlock(ws, client, text, modStreamId)) return;const username = client.user ? client.user.display_name : client.anonId;const coreUsername = client.user ? client.user.username : null;@@@ -828,7 +828,7 @@ class ChatServer {let replyTo = null;if (replyToId) {try {-- const parent = db.getChatMessageById(replyToId);++ const parent = await db.getChatMessageById(replyToId);const parentStillVisible = parent && !parent.is_deleted&& (!parent.auto_delete_at || new Date(parent.auto_delete_at).getTime() > Date.now());if (parentStillVisible) {@@@ -844,8 +844,8 @@ class ChatServer {const requestedAutoDeleteMinutes = parseInt(msg.auto_delete_minutes, 10);const allowViewerAutoDelete = !client.streamId-- || this._getChannelChatSettings(client.streamId).viewer_auto_delete_enabled !== 0-- || permissions.canModerateStream(client.user, client.streamId);++ || (await this._getChannelChatSettings(client.streamId)).viewer_auto_delete_enabled !== 0++ || await permissions.canModerateStream(client.user, client.streamId);const autoDeleteAt = Number.isFinite(requestedAutoDeleteMinutes)&& requestedAutoDeleteMinutes >= MIN_CHAT_AUTO_DELETE_MINUTES&& allowViewerAutoDelete@@@ -896,7 +896,7 @@ class ChatServer {let vipBadgePending = null;if (client.user?.id && client.channelUserId) {try {-- const r = vipBadges.forMessage(this._subjectOfUser(client.user), db.subjectFor(client.channelUserId));++ const r = vipBadges.forMessage(await this._subjectOfUser(client.user), await db.subjectFor(client.channelUserId));if (r.badge) chatMsg.vip_badge = r.badge;else vipBadgePending = r.pending;} catch { /* no badge */ }@@@ -904,7 +904,7 @@ class ChatServer {// Save to databasetry {-- const result = db.saveChatMessage({++ const result = await db.saveChatMessage({stream_id: client.streamId || null,channel_user_id: client.channelUserId || null,user_id: client.user?.id,@@@ -933,7 +933,7 @@ class ChatServer {// stream), AI chat viewers (stream chat), the PowerChat overlay relay (any channel chat) —// are ONE call to Live per message; the coin result comes back for this socket.let isChannelMod = false;-- try { isChannelMod = !!(client.user && client.channelUserId && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }++ try { isChannelMod = !!(client.user && client.channelUserId && await permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }const liveReactions = {award: !!(client.user?.id && client.streamId),ai: !!client.streamId,@@@ -943,13 +943,13 @@ class ChatServer {// Welcome first-time chatters in this streamer's channelif (client.streamId) {try {-- const stream = ctx.getStreamById(client.streamId);++ const stream = await ctx.getStreamById(client.streamId);if (stream?.user_id) {const chatterKey = client.user ? `user:${client.user.id}` : `anon:${client.anonId}`;-- if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {-- db.recordFirstChat(chatterKey, stream.user_id);++ if (await db.isFirstChatInChannel(chatterKey, stream.user_id)) {++ await db.recordFirstChat(chatterKey, stream.user_id);const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';-- this.broadcastToStream(client.streamId, {++ await this.broadcastToStream(client.streamId, {type: 'system',message: `Welcome ${welcomeName} to the chat! 👋`,timestamp: new Date().toISOString(),@@@ -965,13 +965,13 @@ class ChatServer {// viewers) plus the specific stream room. This subsumes cross-slot// forwarding so a viewer isn't interrupted when the streamer switches// slots or briefly drops offline.-- this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);++ await this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);// Surface on the homepage global feed (tagged with the channel).-- if (client.streamId) this.forwardToGlobal(client.streamId, chatMsg);-- else this.forwardToGlobalByChannel(client.channelUserId, chatMsg);++ if (client.streamId) await this.forwardToGlobal(client.streamId, chatMsg);++ else await this.forwardToGlobalByChannel(client.channelUserId, chatMsg);// Auto-translate (async): foreign → English for everyone, English → the channel's// language for a non-English streamer. Lands as a follow-up 'chat_translation'.-- this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);++ await this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);if (vipBadgePending) this._followVipBadge(vipBadgePending, chatMsg, client.channelUserId, client.streamId);}if (client.streamId) {@@@ -979,7 +979,7 @@ class ChatServer {// Trigger server-side TTS synthesis (async, non-blocking).// identityKey uses the immutable login handle (or anon id) so the// per-user voice is stable even if the display name changes.-- this.synthesizeAndBroadcastTTS(++ await this.synthesizeAndBroadcastTTS(client.streamId,username,text,@@@ -991,7 +991,7 @@ class ChatServer {);// Check for 101soundboards links in the message (async, non-blocking)-- this.processSoundboard(ws, client, text);++ await this.processSoundboard(ws, client, text);// AI chat viewers react to REAL typed chat (streamer or viewers) — sent with the// other Live reactions below. Bots inject via broadcastToStream directly, so this@@@ -1000,13 +1000,13 @@ class ChatServer {} else if (!client.channelUserId) {// Pure global chat (homepage) — offline channel messages were already// delivered to the channel room above.-- this.broadcastGlobal(chatMsg);++ await this.broadcastGlobal(chatMsg);} else if (this._channelOwnerInRoom(client.channelUserId)) {// OFFLINE channel chat with the channel OWNER present in their own room:// synthesize TTS so their "TTS on my channel even when offline" option has// audio to play (client-side settings + the one-speaking-tab lock decide// whether it's actually audible). Owner absent = skip the synth cost.-- this.synthesizeAndBroadcastTTS(++ await this.synthesizeAndBroadcastTTS(null,username,text,@@@ -1068,11 +1068,11 @@ class ChatServer {* the text of /me and /tts): length, anonymous, links, GIF hosts, followers-only, account age,* the anti-slur filter. Tells the sender and returns true when the line is refused.*/-- _chatRulesBlock(ws, client, text, modStreamId) {++ async _chatRulesBlock(ws, client, text, modStreamId) {if (!modStreamId) return false;-- const chatSettings = this._getChannelChatSettings(modStreamId);++ const chatSettings = await this._getChannelChatSettings(modStreamId);const isStaff = permissions.can(client.user, 'staff.moderation.bypass');-- const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);++ const canModerateThisStream = await permissions.canModerateStream(client.user, modStreamId);// Max message lengthconst maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));@@@ -1091,7 +1091,7 @@ class ChatServer {// VIP does not count), the streamer, channel moderators and chat staff. Unknown (Live not// asked yet, or unreachable) counts as not subscribed; _warmSubOnly asked before this line.if (chatSettings.sub_only && !isStaff && !canModerateThisStream) {-- const ownerId = ctx.getStreamById(modStreamId)?.user_id || null;++ const ownerId = (await ctx.getStreamById(modStreamId))?.user_id || null;const allowed = !!(client.user && ownerId && (Number(ownerId) === Number(client.user.id) || ctx.isSubscriber(client.user.id, ownerId)));if (!allowed) {this.sendTo(ws, { type: 'system', message: client.user ? SUB_ONLY_REFUSED : SUB_ONLY_ANON });@@@ -1126,7 +1126,7 @@ class ChatServer {// Followers onlyif (chatSettings.followers_only && client.user && !isStaff) {-- const stream = ctx.getStreamById(modStreamId);++ const stream = await ctx.getStreamById(modStreamId);if (stream && stream.user_id !== client.user.id && !ctx.isFollowing(client.user.id, stream.user_id)) {this.sendTo(ws, { type: 'system', message: 'This chat is currently followers-only.' });return true;@@@ -1165,12 +1165,12 @@ class ChatServer {* IP approval mode for text that is not a plain chat line (/me, /tts): refused until the* streamer approves the address (plain chat lines are held for review in handleChatMessage).*/-- _awaitingIpApproval(ws, client, modStreamId) {++ async _awaitingIpApproval(ws, client, modStreamId) {if (!modStreamId || !client.ip) return false;try {-- const stream = ctx.getStreamById(modStreamId);-- const channel = stream?.channel_id ? ctx.getChannelById(stream.channel_id) : (stream ? ctx.getChannelByUserId(stream.user_id) : null);-- if (!channel || !ctx.getChannelModerationSettings(channel.id)?.ip_approval_mode) return false;++ const stream = await ctx.getStreamById(modStreamId);++ const channel = stream?.channel_id ? await ctx.getChannelById(stream.channel_id) : (stream ? await ctx.getChannelByUserId(stream.user_id) : null);++ if (!channel || !(await ctx.getChannelModerationSettings(channel.id))?.ip_approval_mode) return false;if (client.user && (permissions.isGlobalModOrAbove(client.user) || stream.user_id === client.user.id)) return false;if (ctx.isIpApproved(channel.id, client.ip)) return false;} catch { return false; }@@@ -1185,22 +1185,22 @@ class ChatServer {*/async _warmSubOnly(client) {if (!client || !client.user) return;-- const modStreamId = client.streamId || this._moderationStreamFor(client);-- if (!modStreamId || !this._getChannelChatSettings(modStreamId).sub_only) return;-- if (permissions.canModerateStream(client.user, modStreamId)) return;-- const ownerId = ctx.getStreamById(modStreamId)?.user_id || null;++ const modStreamId = client.streamId || await this._moderationStreamFor(client);++ if (!modStreamId || !(await this._getChannelChatSettings(modStreamId)).sub_only) return;++ if (await permissions.canModerateStream(client.user, modStreamId)) return;++ const ownerId = (await ctx.getStreamById(modStreamId))?.user_id || null;if (!ownerId || Number(ownerId) === Number(client.user.id)) return;const state = ctx.subscriberState(client.user.id, ownerId);if (state !== true) await ctx.ensureSubscriber(client.user.id, ownerId, { maxAgeMs: state === false ? 5000 : 0 });}/** /me and /tts put text in the room: the same rules as a chat line. */-- _commandTextBlocked(ws, client, text) {-- const modStreamId = client.streamId || this._moderationStreamFor(client);-- return this._awaitingIpApproval(ws, client, modStreamId) || this._chatRulesBlock(ws, client, text, modStreamId);++ async _commandTextBlocked(ws, client, text) {++ const modStreamId = client.streamId || await this._moderationStreamFor(client);++ return await this._awaitingIpApproval(ws, client, modStreamId) || await this._chatRulesBlock(ws, client, text, modStreamId);}-- handleBangCommand(ws, client, text) {++ async handleBangCommand(ws, client, text) {const parts = text.trim().split(/\s+/);const cmd = parts[0].toLowerCase();@@@ -1244,10 +1244,10 @@ class ChatServer {}if (client.streamId) {-- this.broadcastToStream(client.streamId, gottiMsg);-- this.forwardToGlobal(client.streamId, gottiMsg);++ await this.broadcastToStream(client.streamId, gottiMsg);++ await this.forwardToGlobal(client.streamId, gottiMsg);} else {-- this.broadcastGlobal(gottiMsg);++ await this.broadcastGlobal(gottiMsg);}return;}@@@ -1257,7 +1257,7 @@ class ChatServer {this.sendTo(ws, { type: 'system', message: 'Soundboard commands only work in a stream chat.' });return;}-- const chatSettings = this._getChannelChatSettings(client.streamId);++ const chatSettings = await this._getChannelChatSettings(client.streamId);if (chatSettings.soundboard_enabled === 0) {this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });return;@@@ -1271,7 +1271,7 @@ class ChatServer {this.sendTo(ws, { type: 'system', message: 'Usage: !sb <sound-id or 101soundboards URL> [100p|-100p] [0.5-3 speed]' });return;}-- this.processSoundboard(ws, client, text);++ await this.processSoundboard(ws, client, text);return;}@@@ -1283,7 +1283,7 @@ class ChatServer {const args = text.slice(parts[0].length).trim();try {-- const stream = ctx.getStreamById(client.streamId);++ const stream = await ctx.getStreamById(client.streamId);if (!stream?.user_id) {this.sendTo(ws, { type: 'system', message: 'Could not resolve the current stream owner.' });return;@@@ -1310,8 +1310,8 @@ class ChatServer {userId: client.user.id,username: client.user.display_name || client.user.username,input: args,-- }).then(({ request }) => {-- this.broadcastToStream(client.streamId, {++ }).then(async ({ request }) => {++ await this.broadcastToStream(client.streamId, {type: 'system',message: `${request.username} added “${request.title}”${request.duration_seconds ? ` (${Math.floor(request.duration_seconds / 60)}m${request.duration_seconds % 60}s)` : ''} to the media queue for ${request.cost} gold.`,timestamp: new Date().toISOString(),@@@ -1360,14 +1360,14 @@ class ChatServer {}case '!skip': {-- if (!this.canModerate(client) && client.user?.id !== stream.user_id) {++ if (!await this.canModerate(client) && client.user?.id !== stream.user_id) {this.sendTo(ws, { type: 'system', message: 'Only the streamer or a moderator can skip media.' });return;}// Live runs finishCurrent(streamer, 'skipped') then startNext(streamer).-- ctx.effects.mediaQueue('skip', { streamerId: stream.user_id, actorUserId: client.user?.id || null }).then(({ ended, next }) => {++ ctx.effects.mediaQueue('skip', { streamerId: stream.user_id, actorUserId: client.user?.id || null }).then(async ({ ended, next }) => {if (ended) {-- this.broadcastToStream(client.streamId, {++ await this.broadcastToStream(client.streamId, {type: 'system',message: `Skipped: ${ended.title}${next ? ` • Up next: ${next.title}` : ''}`,timestamp: new Date().toISOString(),@@@ -1414,7 +1414,7 @@ class ChatServer {default:// Unknown !command → try a per-channel viewer-uploaded sound clip.// Forward trailing tokens (e.g. "500p", "0.5") as pitch/speed args.-- this.triggerChannelSound(ws, client, stream, cmd.slice(1), parts.slice(1));++ await this.triggerChannelSound(ws, client, stream, cmd.slice(1), parts.slice(1));return;}} catch (err) {@@@ -1437,14 +1437,14 @@ class ChatServer {* Silent no-op when the command is not a registered sound so unknown* commands don't spam the chat.*/-- triggerChannelSound(ws, client, stream, command, args = [], relay = null) {++ async triggerChannelSound(ws, client, stream, command, args = [], relay = null) {try {if (!stream?.user_id || !command) return;const cmd = String(command).toLowerCase();-- const sound = db.getChannelSoundByCommand(stream.user_id, cmd);++ const sound = await db.getChannelSoundByCommand(stream.user_id, cmd);if (!sound) return; // not a sound command — stay silent-- const chatSettings = this._getChannelChatSettings(client.streamId);++ const chatSettings = await this._getChannelChatSettings(client.streamId);if (chatSettings.custom_sounds_enabled === 0) {if (ws) this.sendTo(ws, { type: 'system', message: 'This streamer has disabled chat sound commands.' });return;@@@ -1528,7 +1528,7 @@ class ChatServer {// Persist the announce so it survives a reload — history rebuilds the// rich sound row from metadata (the audio itself is never replayed).try {-- const saved = db.saveChatMessage({++ const saved = await db.saveChatMessage({stream_id: client.streamId || null,channel_user_id: stream.user_id,user_id: client.user?.id,@@@ -1542,10 +1542,10 @@ class ChatServer {});if (saved.lastInsertRowid) soundMsg.id = Number(saved.lastInsertRowid);} catch { /* non-critical */ }-- this.broadcastToStream(client.streamId, soundMsg);++ await this.broadcastToStream(client.streamId, soundMsg);// Also surface it on the global chat feed / global overlay (with stream_channel).-- this.forwardToGlobal(client.streamId, soundMsg);-- audioQueue.enqueue({++ await this.forwardToGlobal(client.streamId, soundMsg);++ await audioQueue.enqueue({kind: 'channel-sound',streamId: client.streamId,requestedBy: username,@@@ -1562,7 +1562,7 @@ class ChatServer {/*** Handle chat commands*/-- handleCommand(ws, client, text) {++ async handleCommand(ws, client, text) {const parts = text.slice(1).split(' ');const cmd = parts[0].toLowerCase();const args = parts.slice(1).join(' ');@@@ -1571,7 +1571,7 @@ class ChatServer {switch (cmd) {case 'ai': {// /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.-- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'Only moderators can control the AI viewers.' }); return; }++ if (!await this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'Only moderators can control the AI viewers.' }); return; }// Live's ai-chatbot-service.onModCommand answers (AI viewers stay in Live until OpenVibe.AI).ctx.effects.aiModCommand(client.channelUserId, client.streamId, parts.slice(1), { by: client.user?.username }).then((reply) => this.sendTo(ws, { type: 'system', message: reply || 'ok' }))@@@ -1583,7 +1583,7 @@ class ChatServer {type: 'system',message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +`\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +-- (this.canModerate(client)++ (await this.canModerate(client)? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear (screens only), /slow <seconds|off>, /subonly [off], /skiptts [id], /cleartts`: ''),});@@@ -1594,7 +1594,7 @@ class ChatServer {this.sendTo(ws, { type: 'system', message: 'Usage: /tts <message>' });return;}-- if (this._commandTextBlocked(ws, client, args)) return;++ if (await this._commandTextBlocked(ws, client, args)) return;{const ttsMsg = {type: 'tts',@@@ -1615,10 +1615,10 @@ class ChatServer {}} catch { /* non-critical */ }}-- this._broadcastToRoom(client, ttsMsg);++ await this._broadcastToRoom(client, ttsMsg);// Also synthesize server-side TTS for site-wide mode-- this.synthesizeAndBroadcastTTS(++ await this.synthesizeAndBroadcastTTS(client.streamId,ttsMsg.username,args,@@@ -1655,7 +1655,7 @@ class ChatServer {break;}try {-- const stream = ctx.getStreamById(client.streamId);++ const stream = await ctx.getStreamById(client.streamId);if (stream && stream.started_at) {const start = new Date(stream.started_at.replace(' ', 'T') + 'Z').getTime();const elapsed = Date.now() - start;@@@ -1688,9 +1688,9 @@ class ChatServer {this.sendTo(ws, { type: 'system', message: 'Usage: /me <action>' });break;}-- if (this._commandTextBlocked(ws, client, args)) break;++ if (await this._commandTextBlocked(ws, client, args)) break;const username = client.user?.display_name || client.anonId;-- this._broadcastToRoom(client, {++ await this._broadcastToRoom(client, {type: 'chat',username,core_username: client.user?.username || null,@@@ -1720,19 +1720,19 @@ class ChatServer {// The room's TTS and sound queue (./audio-queue.js): moderators and the broadcaster.// /skiptts [request id] skips the clip playing now (or that request); /cleartts// skips everything playing or waiting.-- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); return; }++ if (!await this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); return; }const room = audioQueue.roomKey({ streamId: client.streamId, channelUserId: client.streamId ? null : client.channelUserId });if (!room) { this.sendTo(ws, { type: 'system', message: 'No TTS or sounds here.' }); return; }const actor = client.user ? `user:${client.user.username}` : null;if (cmd === 'skiptts') {const id = parseInt(argParts[0], 10);-- const skipped = audioQueue.skip(room, { id: Number.isFinite(id) && id > 0 ? id : null, actor });++ const skipped = await audioQueue.skip(room, { id: Number.isFinite(id) && id > 0 ? id : null, actor });this.sendTo(ws, { type: 'system', message: skipped ? `Skipped ${skipped.kind === 'tts' ? 'TTS' : 'sound'} #${skipped.id}${skipped.requested_by ? ` from ${skipped.requested_by}` : ''}.` : 'Nothing to skip.' });-- if (skipped) this.logChatModeration(client, 'tts_skip', { request_id: skipped.id, kind: skipped.kind });++ if (skipped) await this.logChatModeration(client, 'tts_skip', { request_id: skipped.id, kind: skipped.kind });} else {-- const ids = audioQueue.clear(room, { actor });++ const ids = await audioQueue.clear(room, { actor });this.sendTo(ws, { type: 'system', message: ids.length ? `Cleared ${ids.length} TTS/sound request${ids.length === 1 ? '' : 's'}.` : 'The TTS and sound queue is empty.' });-- if (ids.length) this.logChatModeration(client, 'tts_clear', { request_ids: ids });++ if (ids.length) await this.logChatModeration(client, 'tts_clear', { request_ids: ids });}return;}@@@ -1740,17 +1740,17 @@ class ChatServer {case 'clear':// Twitch semantics: every screen in the room is cleared, nothing is deleted. Removing// lines for good is a purge (dashboard) or a delete.-- if (this.canModerate(client)) {-- this._broadcastToRoom(client, { type: 'clear' });++ if (await this.canModerate(client)) {++ await this._broadcastToRoom(client, { type: 'clear' });this.sendTo(ws, { type: 'system', message: 'Chat cleared on screen; messages stay in history — use purge to remove them.' });-- this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');++ await this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');} else {this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });}break;case 'slow': {-- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }++ if (!await this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }let seconds;if (args === 'off' || args === 'disable' || args === '0') {seconds = 0;@@@ -1759,16 +1759,16 @@ class ChatServer {if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;}this._setChannelModes(ws, client, { slow_mode_seconds: seconds }, 'slow mode')-- .then((ok) => { if (ok) this.logChatModeration(client, 'slowmode_update', { seconds }); });++ .then(async (ok) => { if (ok) await this.logChatModeration(client, 'slowmode_update', { seconds }); });break;}case 'subonly': {// /subonly [on] · /subonly off — the streamer, channel mods and chat staff.-- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }++ if (!await this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }const off = ['off', 'disable', '0', 'false', 'no'].includes(String(args || '').trim().toLowerCase());this._setChannelModes(ws, client, { sub_only: off ? 0 : 1 }, 'sub-only mode')-- .then((ok) => { if (ok) this.logChatModeration(client, 'subonly_update', { enabled: !off }); });++ .then(async (ok) => { if (ok) await this.logChatModeration(client, 'subonly_update', { enabled: !off }); });break;}@@@ -1789,11 +1789,11 @@ class ChatServer {visibility: 'public',user_id: userId || undefined,stream_id: client.streamId || undefined,-- }).then((paste) => {++ }).then(async (paste) => {const siteUrl = process.env.SITE_URL || '';const pasteUrl = `${siteUrl}/p/${paste.slug}`;// Show link to everyone in stream-- this._broadcastToRoom(client, {++ await this._broadcastToRoom(client, {type: 'system',message: `📋 ${client.displayName || client.username || 'Anonymous'} shared a paste: ${pasteUrl}`,});@@@ -1825,7 +1825,7 @@ class ChatServer {* the row (re-checking the moderator) and Chat's ban cache is refreshed before the reply.*/async _handleModAction(ws, client, action, args) {-- if (!this.canModerate(client)) {++ if (!await this.canModerate(client)) {this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });return;}@@@ -1833,7 +1833,7 @@ class ChatServer {const target = args.split(' ')[0];if (!target) return;// The stream whose moderators may do this (offline channel rooms: the latest stream).-- const moderationStreamId = client.streamId || this._moderationStreamFor(client);++ const moderationStreamId = client.streamId || await this._moderationStreamFor(client);// Where the ban applies. In an offline channel room it is that channel's stream: a null// stream id is a SITE-WIDE ban row (and /unban with null lifts site-wide rows), which// only global chat — where canModerate() already requires global staff — may write.@@@ -1852,10 +1852,10 @@ class ChatServer {}await banEffect({ action: 'ban', user_id: targetUser.id, reason: 'Banned by moderator', banned_by: client.user.id });this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });-- this._broadcastToRoom(client, {++ await this._broadcastToRoom(client, {type: 'system', message: `${target} has been banned.`});-- this.logChatModeration(client, scoped ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);++ await this.logChatModeration(client, scoped ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);} else {// Ban by anon IDconst anonTarget = this.findClientByAnonId(target, client.streamId);@@@ -1863,7 +1863,7 @@ class ChatServer {await banEffect({ action: 'ban', ip_address: anonTarget.ip, anon_id: target, reason: 'Banned by moderator', banned_by: client.user.id });this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });}-- this.logChatModeration(client, scoped ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });++ await this.logChatModeration(client, scoped ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });}break;}@@@ -1873,7 +1873,7 @@ class ChatServer {const expires = new Date(Date.now() + duration * 1000).toISOString();if (targetUser) {await banEffect({ action: 'ban', user_id: targetUser.id, reason: `Timeout ${duration}s`, banned_by: client.user.id, expires_at: expires });-- this.logChatModeration(client, scoped ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);++ await this.logChatModeration(client, scoped ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);}this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });break;@@@ -1882,7 +1882,7 @@ class ChatServer {const targetUser = await ctx.ensureUserByUsername(target);if (targetUser) {await banEffect({ action: 'unban', user_id: targetUser.id });-- this.logChatModeration(client, scoped ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);++ await this.logChatModeration(client, scoped ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);}this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });break;@@@ -1909,9 +1909,9 @@ class ChatServer {// TTS audio delivery: to the stream room while live, to the CHANNEL room for// offline channel chat (streamId null).-- _broadcastTtsPayload(streamId, channelUserId, payload) {-- if (streamId) this.broadcastToStream(streamId, payload);-- else if (channelUserId) this.broadcastToChannelRoom(channelUserId, null, payload);++ async _broadcastTtsPayload(streamId, channelUserId, payload) {++ if (streamId) await this.broadcastToStream(streamId, payload);++ else if (channelUserId) await this.broadcastToChannelRoom(channelUserId, null, payload);}// streamId may be null for OFFLINE channel chat — pass channelUserId instead and@@@ -1920,7 +1920,7 @@ class ChatServer {// Queues the utterance (./audio-queue.js); it is synthesized and sent when its turn comes.// Returns the queue's answer ({ queued, id } or { queued: false, reason }) or undefined when// the message is not read at all.-- synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null, channelUserId = null, ttsKey = null) {++ async synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null, channelUserId = null, ttsKey = null) {// Queue accounting key: per-stream when live, per-channel when offline.const queueKey = streamId || (channelUserId ? `ch:${channelUserId}` : null);// Duplicate suppression by MESSAGE IDENTITY, not content: the same chat@@@ -1949,7 +1949,7 @@ class ChatServer {if (!settings.enabled) return;const limits = ttsEngine.getQueueLimits();-- const r = audioQueue.enqueue({++ const r = await audioQueue.enqueue({kind: 'tts',streamId: streamId || null,channelUserId: streamId ? null : channelUserId,@@@ -1983,7 +1983,7 @@ class ChatServer {// falls back to the site default when unset. Without this the server synth always// truncated at the global 200 even when the channel allowed more.let ttsMaxOverride;-- try { ttsMaxOverride = Number(this._getChannelChatSettings(p.streamId).tts_max_length) || undefined; } catch { /* use engine default */ }++ try { ttsMaxOverride = Number((await this._getChannelChatSettings(p.streamId)).tts_max_length) || undefined; } catch { /* use engine default */ }let result;if (!voiceId && settings.perUserVoices) {@@@ -2079,7 +2079,7 @@ class ChatServer {const streamId = client?.streamId || null;if (!streamId) return;-- const chatSettings = this._getChannelChatSettings(streamId);++ const chatSettings = await this._getChannelChatSettings(streamId);if (chatSettings.soundboard_enabled === 0) {if (String(text || '').trim().startsWith('!sb')) {this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });@@@ -2166,7 +2166,7 @@ class ChatServer {};// Persist so the announce survives a reload (rebuilt from metadata).try {-- const saved = db.saveChatMessage({++ const saved = await db.saveChatMessage({stream_id: streamId,user_id: client.user?.id,anon_id: client.anonId,@@@ -2178,9 +2178,9 @@ class ChatServer {});if (saved.lastInsertRowid) sbMsg.id = Number(saved.lastInsertRowid);} catch { /* non-critical */ }-- this.broadcastToStream(streamId, sbMsg);++ await this.broadcastToStream(streamId, sbMsg);-- audioQueue.enqueue({++ await audioQueue.enqueue({kind: 'soundboard',streamId,requestedBy: username,@@@ -2202,16 +2202,16 @@ class ChatServer {* Uses the permission layer: admin, global_mod, stream owner, or channel mod.* Streamers do NOT get mod powers in other people's chats.*/-- canModerate(client) {++ async canModerate(client) {if (!client.user) return false;// Offline channel chat: the channel's latest stream stands in (see _moderationStreamFor).-- const sid = client.streamId || this._moderationStreamFor(client);-- return !!sid && permissions.canModerateStream(client.user, sid) || permissions.isGlobalModOrAbove(client.user);++ const sid = client.streamId || await this._moderationStreamFor(client);++ return !!sid && await permissions.canModerateStream(client.user, sid) || permissions.isGlobalModOrAbove(client.user);}/** @deprecated Use canModerate(client) — kept temporarily for any external callers */-- isMod(client) {-- return this.canModerate(client);++ async isMod(client) {++ return await this.canModerate(client);}findClientByAnonId(anonId, streamId) {@@@ -2276,15 +2276,15 @@ class ChatServer {}/** Language a channel lives in ('en' when unknown). Live's i18n decides; cached in live-context. */-- _channelLanguage(channelUserId) {++ async _channelLanguage(channelUserId) {if (!channelUserId) return 'en';-- try { return ctx.channelLanguage(channelUserId); } catch { return 'en'; }++ try { return await ctx.channelLanguage(channelUserId); } catch { return 'en'; }}/** A signed-in user's Network subject (the projection's subject_id, else the ctx_users row). */-- _subjectOfUser(user) {++ async _subjectOfUser(user) {if (!user) return null;-- return user.subject_id || db.subjectFor(user.id) || null;++ return user.subject_id || await db.subjectFor(user.id) || null;}/**@@@ -2293,13 +2293,13 @@ class ChatServer {* keep it in the row's metadata so history shows it. Fire-and-forget; never throws.*/_followVipBadge(pending, chatMsg, channelUserId, streamId) {-- Promise.resolve(pending).then((badge) => {++ Promise.resolve(pending).then(async (badge) => {if (!badge || !chatMsg || !chatMsg.id) return;const evt = { type: 'chat_vip_badge', id: chatMsg.id, vip_badge: badge, stream_id: streamId || null, channel_user_id: channelUserId || null, timestamp: new Date().toISOString() };-- this.broadcastToChannelRoom(channelUserId, streamId, evt);-- if (streamId) this.forwardToGlobal(streamId, evt);-- else this.forwardToGlobalByChannel(channelUserId, evt);-- try { db.mergeChatMessageMetadata(chatMsg.id, { vip_badge: badge }); } catch { /* */ }++ await this.broadcastToChannelRoom(channelUserId, streamId, evt);++ if (streamId) await this.forwardToGlobal(streamId, evt);++ else await this.forwardToGlobalByChannel(channelUserId, evt);++ try { await db.mergeChatMessageMetadata(chatMsg.id, { vip_badge: badge }); } catch { /* */ }}).catch(() => { /* no badge */ });}@@@ -2308,22 +2308,22 @@ class ChatServer {* 'chat_translation' event (clients attach it under the message by id). Persisted into* chat_messages.metadata so history shows it too. Fire-and-forget; never throws.*/-- _maybeTranslate(chatMsg, channelUserId, streamId) {++ async _maybeTranslate(chatMsg, channelUserId, streamId) {if (!chatMsg || !chatMsg.message || chatMsg.message_type && chatMsg.message_type !== 'chat') return;// Live's i18n translates (and answers null when translation is unavailable).let chanUid = channelUserId || null;-- if (!chanUid && streamId) { try { chanUid = ctx.getStreamById(streamId)?.user_id || null; } catch { /* */ } }++ if (!chanUid && streamId) { try { chanUid = (await ctx.getStreamById(streamId))?.user_id || null; } catch { /* */ } }const text = String(chatMsg.message).replace(/^\s*\.\s?/, ''); // ".msg" = tts-off marker-- ctx.effects.translate(text, chanUid).then((tr) => {++ ctx.effects.translate(text, chanUid).then(async (tr) => {if (!tr || !tr.text) return;const evt = {type: 'chat_translation', id: chatMsg.id || null, from: tr.from, to: tr.to, text: tr.text,stream_id: streamId || null, channel_user_id: chanUid, timestamp: new Date().toISOString(),};-- this.broadcastToChannelRoom(chanUid, streamId, evt);-- if (streamId) this.forwardToGlobal(streamId, evt);-- else this.forwardToGlobalByChannel(chanUid, evt);-- if (chatMsg.id) { try { db.mergeChatMessageMetadata(chatMsg.id, { translation: tr }); } catch { /* */ } }++ await this.broadcastToChannelRoom(chanUid, streamId, evt);++ if (streamId) await this.forwardToGlobal(streamId, evt);++ else await this.forwardToGlobalByChannel(chanUid, evt);++ if (chatMsg.id) { try { await db.mergeChatMessageMetadata(chatMsg.id, { translation: tr }); } catch { /* */ } }}).catch(() => { /* best-effort */ });}@@@ -2332,17 +2332,17 @@ class ChatServer {* line frame (chat, /me, /tts and its audio), or null — the usual case, everyone gets it.* Computed once per frame object, however many rooms it goes to.*/-- _blockersOf(data) {++ async _blockersOf(data) {if (!data || typeof data !== 'object' || !LINE_FRAMES.has(data.type)) return null;if (this._blockMemo.has(data)) return this._blockMemo.get(data);let out = null;try {let authorId = data.user_id || null;if (!authorId && data.type === 'tts-audio' && /^user:/.test(String(data.sender_key || ''))) {-- authorId = ctx.getUserByUsername(String(data.sender_key).slice(5))?.id || null;++ authorId = (await ctx.getUserByUsername(String(data.sender_key).slice(5)))?.id || null;}-- const subject = authorId ? db.subjectFor(authorId) : null;-- const list = subject ? networkBlocks.blockersOf(subject) : [];++ const subject = authorId ? await db.subjectFor(authorId) : null;++ const list = subject ? await networkBlocks.blockersOf(subject) : [];if (list.length) out = new Set(list);} catch { out = null; }this._blockMemo.set(data, out);@@@ -2350,8 +2350,8 @@ class ChatServer {}/** Does this socket's person not get the frame (they blocked its author)? */-- _blockedFor(client, blockers) {-- return !!(blockers && client.user && blockers.has(this._subjectOfUser(client.user)));++ async _blockedFor(client, blockers) {++ return !!(blockers && client.user && blockers.has(await this._subjectOfUser(client.user)));}/**@@@ -2359,17 +2359,17 @@ class ChatServer {* in an offline channel chat, else pure global chat. broadcastToStream(null) would reach every* client without a stream — global chat AND every other channel's offline room.*/-- _broadcastToRoom(client, data) {-- if (client.streamId) this.broadcastToStream(client.streamId, data);-- else if (client.channelUserId) this.broadcastToChannelRoom(client.channelUserId, null, data);-- else this.broadcastGlobal(data);++ async _broadcastToRoom(client, data) {++ if (client.streamId) await this.broadcastToStream(client.streamId, data);++ else if (client.channelUserId) await this.broadcastToChannelRoom(client.channelUserId, null, data);++ else await this.broadcastGlobal(data);}-- broadcastToStream(streamId, data) {++ async broadcastToStream(streamId, data) {const msg = JSON.stringify(data);-- const blockers = this._blockersOf(data);++ const blockers = await this._blockersOf(data);for (const [ws, client] of this.clients) {-- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {++ if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {ws.send(msg);}}@@@ -2381,12 +2381,12 @@ class ChatServer {* live-session stream room (`streamId`, for backward-compat with clients that* joined before channel rooms existed). Deduped per connection.*/-- broadcastToChannelRoom(channelUserId, streamId, data) {++ async broadcastToChannelRoom(channelUserId, streamId, data) {const msg = JSON.stringify(data);-- const blockers = this._blockersOf(data);++ const blockers = await this._blockersOf(data);for (const [ws, client] of this.clients) {if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > MAX_SEND_BACKPRESSURE) continue;-- if (this._blockedFor(client, blockers)) continue;++ if (await this._blockedFor(client, blockers)) continue;const inChannel = channelUserId && client.channelUserId === channelUserId;const inStream = streamId && client.streamId === streamId;if (inChannel || inStream) ws.send(msg);@@@ -2398,27 +2398,27 @@ class ChatServer {* the channel username (parallels forwardToGlobal but keyed by user id since* there's no live session id to resolve).*/-- forwardToGlobalByChannel(channelUserId, data) {++ async forwardToGlobalByChannel(channelUserId, data) {if (!channelUserId) return;let username = null;-- try { username = ctx.getUserById(channelUserId)?.username; } catch { /* ignore */ }++ try { username = (await ctx.getUserById(channelUserId))?.username; } catch { /* ignore */ }if (!username) return;const globalMsg = JSON.stringify({ ...data, stream_channel: username, source_channel: username });-- const blockers = this._blockersOf(data);++ const blockers = await this._blockersOf(data);for (const [ws, client] of this.clients) {-- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {++ if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {ws.send(globalMsg);}}}-- broadcastGlobal(data) {++ async broadcastGlobal(data) {const msg = JSON.stringify(data);-- const blockers = this._blockersOf(data);++ const blockers = await this._blockersOf(data);for (const [ws, client] of this.clients) {// Pure global clients only (see forwardToGlobal) — channel/stream viewers get// global activity via their dedicated cross-feed socket, not their main one.-- if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {++ if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {ws.send(msg);}}@@@ -2436,12 +2436,12 @@ class ChatServer {* A room's roles or visibility changed: every socket following it learns what it may do now* (`room_access`), and one that may no longer read it leaves (`room_left`). → sockets told*/-- refreshRoomAccess(room) {++ async refreshRoomAccess(room) {const rooms = require('../rooms/rooms');let n = 0;for (const [ws, client] of this.clients) {if (!room || client.roomId !== room.id) continue;-- const a = rooms.access(room, client.user || null);++ const a = await rooms.access(room, client.user || null);n++;if (!a.read) { client.roomId = null; client.roomSlug = null; this.sendTo(ws, { type: 'room_left', reason: 'removed' }); continue; }this.sendTo(ws, { type: 'room_access', room: room.slug, kind: a.kind, role: a.role, can: { post: a.post, moderate: a.moderate, manage: a.manage, join: a.join, talk: a.talk } });@@@ -2466,9 +2466,9 @@ class ChatServer {* channel's emotes/sounds changed, so their chat pickers refresh live* (no page reload). No-op when the owner has no live streams / viewers.*/-- broadcastToOwnerStreams(ownerUserId, data) {++ async broadcastToOwnerStreams(ownerUserId, data) {try {-- const streams = ctx.getLiveStreamsByUserId(ownerUserId) || [];++ const streams = await ctx.getLiveStreamsByUserId(ownerUserId) || [];if (!streams.length) return;const ids = new Set(streams.map(s => String(s.id)));const msg = JSON.stringify(data);@@@ -2484,13 +2484,13 @@ class ChatServer {* Forward a stream message to all global-connected clients* so the global chat feed shows activity from every stream.*/-- forwardToGlobal(streamId, data) {++ async forwardToGlobal(streamId, data) {// Look up stream owner username + slot title/slug (cached per stream)if (!this._streamNameCache) this._streamNameCache = new Map();let info = this._streamNameCache.get(streamId);if (!info || typeof info !== 'object') {try {-- const stream = ctx.getStreamById(streamId);++ const stream = await ctx.getStreamById(streamId);info = {username: stream?.username || `stream-${streamId}`,title: stream?.managed_stream_title || stream?.title || null,@@@ -2514,9 +2514,9 @@ class ChatServer {source_managed_id: info.managedId,source_is_live: 1, // this path only fires for a live send});-- const blockers = this._blockersOf(data);++ const blockers = await this._blockersOf(data);for (const [ws, client] of this.clients) {-- if (this._blockedFor(client, blockers)) continue;++ if (await this._blockedFor(client, blockers)) continue;// Only PURE global clients (no stream AND no channel) — otherwise an// offline-channel viewer's main socket (streamId null, channelUserId set)// would render this via addChatMessage AND get it again as a cross-feed on@@@ -2534,11 +2534,11 @@ class ChatServer {* client can show a stream-title badge + let viewers hop between slots. Only* fires when the streamer has 2+ live slots.*/-- forwardToStreamerRooms(streamId, data) {++ async forwardToStreamerRooms(streamId, data) {try {-- const stream = ctx.getStreamById(streamId);++ const stream = await ctx.getStreamById(streamId);if (!stream || !stream.user_id) return;-- const siblings = ctx.getLiveStreamsByUserId(stream.user_id) || [];++ const siblings = await ctx.getLiveStreamsByUserId(stream.user_id) || [];if (siblings.length < 2) return;const siblingIds = new Set(siblings.map(s => s.id));const payload = JSON.stringify({@@@ -2551,34 +2551,34 @@ class ChatServer {source_channel: stream.username || null,source_is_live: 1,});-- const blockers = this._blockersOf(data);++ const blockers = await this._blockersOf(data);for (const [ws, client] of this.clients) {if (client.streamId && client.streamId !== streamId && siblingIds.has(client.streamId)-- && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {++ && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {ws.send(payload);}}} catch { /* non-critical */ }}-- _broadcastDeletedMessages(streamId, ids) {++ async _broadcastDeletedMessages(streamId, ids) {if (!Array.isArray(ids) || ids.length === 0) return;const payload = { type: 'delete-messages', ids };if (streamId) {// The stream's room, the rest of its channel room (other slots, the offline room, a// channel popout) and the global feed: everywhere the lines were shown.let ownerId = null;-- try { ownerId = ctx.getStreamById(streamId)?.user_id || null; } catch { ownerId = null; }-- this.broadcastToChannelRoom(ownerId, streamId, payload);-- this.forwardToGlobal(streamId, payload);++ try { ownerId = (await ctx.getStreamById(streamId))?.user_id || null; } catch { ownerId = null; }++ await this.broadcastToChannelRoom(ownerId, streamId, payload);++ await this.forwardToGlobal(streamId, payload);return;}-- this.broadcastAll(payload);++ await this.broadcastAll(payload);}-- _sweepExpiredChatMessages() {++ async _sweepExpiredChatMessages() {try {-- const expired = db.deleteExpiredChatMessages(500);++ const expired = await db.deleteExpiredChatMessages(500);if (!expired.length) return;const byScope = new Map();for (const row of expired) {@@@ -2588,7 +2588,7 @@ class ChatServer {}for (const [key, ids] of byScope.entries()) {const streamId = key === 'global' ? null : parseInt(key.split(':')[1], 10);-- this._broadcastDeletedMessages(streamId, ids);++ await this._broadcastDeletedMessages(streamId, ids);}} catch (err) {console.warn('[Chat] Failed to sweep expired auto-delete messages:', err.message);@@@ -2613,7 +2613,7 @@ class ChatServer {* Record viewer snapshots for all active streams.* Called every 60 seconds by the snapshot interval timer.*/-- _recordViewerSnapshots() {++ async _recordViewerSnapshots() {// Collect unique active stream IDsconst streamIds = new Set();for (const [, client] of this.clients) {@@@ -2622,7 +2622,7 @@ class ChatServer {for (const streamId of streamIds) {try {const count = this.getStreamViewerCount(streamId);-- const chatActivity = db.getRecentChatActivity(streamId, 5);++ const chatActivity = await db.getRecentChatActivity(streamId, 5);ctx.effects.viewerSnapshot(streamId, count, chatActivity);} catch (err) {// Non-critical — don't crash the chat server over analytics@@@ -2638,9 +2638,9 @@ class ChatServer {const key = `users-${streamId ?? 'global'}`;if (this._usersListTimers?.has(key)) return; // already scheduledif (!this._usersListTimers) this._usersListTimers = new Map();-- this._usersListTimers.set(key, setTimeout(() => {++ this._usersListTimers.set(key, setTimeout(async () => {this._usersListTimers.delete(key);-- const users = this.getUserList(streamId);++ const users = await this.getUserList(streamId);const data = JSON.stringify({ type: 'users-list', users });for (const [ws, client] of this.clients) {if (client.streamId === streamId && ws.readyState === WebSocket.OPEN) {@@@ -2672,9 +2672,9 @@ class ChatServer {* Send a DM payload to all WebSocket connections belonging to a given user ID.* Used by the DM REST API for real-time delivery.*/-- sendDm(userId, data) {++ async sendDm(userId, data) {const convId = data?.conversation_id;-- if (convId && !dm.isParticipant(convId, userId)) {++ if (convId && !await dm.isParticipant(convId, userId)) {if (DEBUG_DM_DELIVERY) {console.warn(`[DM] blocked delivery to user ${userId} for conversation ${convId} (not a participant)`, data.type, data);}@@@ -2718,19 +2718,19 @@ class ChatServer {* Push a profile/identity update to all chat connections belonging to a user.* Refreshes cached client.user so subsequent messages use the new info.*/-- sendUserUpdate(userId, userData) {++ async sendUserUpdate(userId, userData) {// Live changed the account (admin edit): refresh the projection first. A new name is// also rewritten into this user's stored chat lines, as Live did with its own rows.-- const before = ctx.getUserById(userId);++ const before = await ctx.getUserById(userId);if (userData && userData.id != null) {-- ctx.upsertUser({ ...(before || {}), ...userData });++ await ctx.upsertUser({ ...(before || {}), ...userData });const newChatName = userData.display_name || userData.username;if (before && newChatName && newChatName !== (before.display_name || before.username)) {-- try { db.renameUserChatMessages(userId, newChatName); } catch { /* non-critical */ }++ try { await db.renameUserChatMessages(userId, newChatName); } catch { /* non-critical */ }}}ctx.invalidateUser(userId);-- const freshUser = ctx.getUserById(userId);++ const freshUser = await ctx.getUserById(userId);const payload = JSON.stringify({type: 'user-updated',user: {@@@ -2757,11 +2757,11 @@ class ChatServer {* Broadcast a message to ALL connected chat clients (every stream + global).* Used for server-wide announcements (restarts, updates).*/-- broadcastAll(data) {++ async broadcastAll(data) {const msg = JSON.stringify(data);-- const blockers = this._blockersOf(data);++ const blockers = await this._blockersOf(data);for (const [ws, client] of this.clients) {-- if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {++ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {ws.send(msg);}}@@@ -2811,24 +2811,24 @@ class ChatServer {* A room's slow mode in ms: its channel's saved slow_mode_seconds (channel moderation settings,* the dashboard's value and /slow's). Read from the policy cache, so a restart keeps it.*/-- slowModeMs(streamId) {++ async slowModeMs(streamId) {if (!streamId) return 0;-- return Math.max(0, parseInt(this._getChannelChatSettings(streamId).slow_mode_seconds, 10) || 0) * 1000;++ return Math.max(0, parseInt((await this._getChannelChatSettings(streamId)).slow_mode_seconds, 10) || 0) * 1000;}/** streamId → slow mode ms for the streams with sockets here (Live's presence read). */-- get slowModeByStream() {++ async slowModeByStream() {const out = new Map();-- for (const [, c] of this.clients) if (c.streamId && !out.has(c.streamId)) out.set(c.streamId, this.slowModeMs(c.streamId));++ for (const [, c] of this.clients) if (c.streamId && !out.has(c.streamId)) out.set(c.streamId, await this.slowModeMs(c.streamId));return out;}/** The channel a client's room belongs to: { channel, ownerId } or null (global chat). */async _channelOfRoom(client) {-- const modStreamId = client.streamId || this._moderationStreamFor(client);-- const stream = modStreamId ? ctx.getStreamById(modStreamId) : null;++ const modStreamId = client.streamId || await this._moderationStreamFor(client);++ const stream = modStreamId ? await ctx.getStreamById(modStreamId) : null;const ownerId = (stream && stream.user_id) || client.channelUserId || null;-- let channel = stream && stream.channel_id ? ctx.getChannelById(stream.channel_id) : null;++ let channel = stream && stream.channel_id ? await ctx.getChannelById(stream.channel_id) : null;if (!channel && ownerId) channel = await ctx.ensureChannelForUser(ownerId);return channel ? { channel, ownerId: channel.user_id || ownerId } : null;}@@@ -2860,26 +2860,26 @@ class ChatServer {const n = (this._modeWrites.get(id) || 1) - 1;if (n > 0) this._modeWrites.set(id, n); else this._modeWrites.delete(id);}-- const next = this._modesOf(ctx.getChannelModerationSettings(id));++ const next = this._modesOf(await ctx.getChannelModerationSettings(id));if (fields.slow_mode_seconds !== undefined) next.slow = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);if (fields.sub_only !== undefined) next.sub = fields.sub_only ? 1 : 0;this._announcedModes.set(id, next);// A command is always answered in the room, even when the value did not change.-- this._announceModes(room.ownerId, client.streamId, { slow: fields.slow_mode_seconds !== undefined ? -1 : next.slow, sub: fields.sub_only !== undefined ? -1 : next.sub }, next);++ await this._announceModes(room.ownerId, client.streamId, { slow: fields.slow_mode_seconds !== undefined ? -1 : next.slow, sub: fields.sub_only !== undefined ? -1 : next.sub }, next);return true;}/** Tell a channel's room what changed: the slowmode / subonly frame and a system line for each. */-- _announceModes(ownerUserId, streamId, prev, next) {++ async _announceModes(ownerUserId, streamId, prev, next) {if (!ownerUserId && !streamId) return;-- const say = (frame) => this.broadcastToChannelRoom(ownerUserId || null, streamId || null, frame);++ const say = async (frame) => await this.broadcastToChannelRoom(ownerUserId || null, streamId || null, frame);if (prev.slow !== next.slow) {-- say({ type: 'slowmode', seconds: next.slow });-- say({ type: 'system', message: next.slow > 0 ? `Slow mode enabled: ${next.slow}s between messages` : 'Slow mode disabled.' });++ await say({ type: 'slowmode', seconds: next.slow });++ await say({ type: 'system', message: next.slow > 0 ? `Slow mode enabled: ${next.slow}s between messages` : 'Slow mode disabled.' });}if (prev.sub !== next.sub) {-- say({ type: 'subonly', enabled: !!next.sub });-- say({ type: 'system', message: next.sub ? 'Sub-only mode enabled: only subscribers and moderators can chat.' : 'Sub-only mode disabled.' });++ await say({ type: 'subonly', enabled: !!next.sub });++ await say({ type: 'system', message: next.sub ? 'Sub-only mode enabled: only subscribers and moderators can chat.' : 'Sub-only mode disabled.' });}}@@@ -2888,22 +2888,22 @@ class ChatServer {* sub-only mode differs from what its room was last told (the dashboard changed it), tell the* room. The first read after a start only records the values.*/-- _channelSettingsSeen(channelId, settings) {++ async _channelSettingsSeen(channelId, settings) {const id = Number(channelId);if (!id || this._modeWrites.has(id)) return;const next = this._modesOf(settings);const prev = this._announcedModes.get(id);this._announcedModes.set(id, next);if (!prev || (prev.slow === next.slow && prev.sub === next.sub)) return;-- const channel = ctx.getChannelById(id);-- if (channel && channel.user_id) this._announceModes(channel.user_id, null, prev, next);++ const channel = await ctx.getChannelById(id);++ if (channel && channel.user_id) await this._announceModes(channel.user_id, null, prev, next);}/*** Get channel moderation settings for a stream.* Caches the channel lookup to avoid repeated DB queries.*/-- _getChannelChatSettings(streamId) {++ async _getChannelChatSettings(streamId) {const defaults = {slow_mode_seconds: 0, followers_only: 0, emote_only: 0,allow_anonymous: 1, links_allowed: 1, account_age_gate_hours: 0,@@@ -2935,11 +2935,11 @@ class ChatServer {});if (!streamId) return finalize(defaults);try {-- const stream = ctx.getStreamById(streamId);++ const stream = await ctx.getStreamById(streamId);if (!stream) return finalize(defaults);-- const channel = stream.channel_id ? ctx.getChannelById(stream.channel_id) : ctx.getChannelByUserId(stream.user_id);++ const channel = stream.channel_id ? await ctx.getChannelById(stream.channel_id) : await ctx.getChannelByUserId(stream.user_id);if (!channel) return finalize(defaults);-- return finalize({ ...defaults, ...ctx.getChannelModerationSettings(channel.id) });++ return finalize({ ...defaults, ...await ctx.getChannelModerationSettings(channel.id) });} catch {return finalize(defaults);}@@@ -2949,13 +2949,13 @@ class ChatServer {* Log a moderation action from a chat command (/ban, /timeout, /clear, /slowmode).* Non-critical — failures are silently ignored.*/-- logChatModeration(client, actionType, details = {}, targetUserId = null) {++ async logChatModeration(client, actionType, details = {}, targetUserId = null) {try {-- const stream = client.streamId ? ctx.getStreamById(client.streamId) : null;++ const stream = client.streamId ? await ctx.getStreamById(client.streamId) : null;const channel = stream?.channel_id-- ? ctx.getChannelById(stream.channel_id)-- : stream ? ctx.getChannelByUserId(stream.user_id) : null;-- db.logModerationAction({++ ? await ctx.getChannelById(stream.channel_id)++ : stream ? await ctx.getChannelByUserId(stream.user_id) : null;++ await db.logModerationAction({scope_type: channel ? 'channel' : 'site',scope_id: channel?.id || undefined,actor_user_id: client.user?.id || undefined,@@@ -57,9 +57,9 @@ const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.le* that kept folding while people chatted in a stream ended up with a time range running past the* messages under it. Anyone speaking anywhere since the last card starts a new card.*/--function persist(db, commits) {++async function persist(db, commits) {const nowIso = new Date().toISOString();-- const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');++ const newest = await db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');let prev = null;if (newest && newest.message_type === 'system' && newest.metadata) {try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }@@@ -68,11 +68,11 @@ function persist(db, commits) {const seen = new Set(commits.map(c => c.hash));const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };-- db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);++ await db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);return { id: newest.id, meta, folded: true };}const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };-- const res = db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });++ const res = await db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });return { id: Number(res && (res.lastInsertRowid || res.lastID || res.id)) || null, meta, folded: false };}@@@ -81,16 +81,16 @@ function persist(db, commits) {* to arrive makes the card; the other finds the head claimed, notes when it saw it, and changes* nothing else. Returns { duplicate: true, id } or { duplicate: false, saved }.*/--function claimRelease(db, { head, commits, via, eventId = null, now = Date.now() }) {++async function claimRelease(db, { head, commits, via, eventId = null, now = Date.now() }) {const seenCol = via === 'events' ? 'event_at' : 'bridge_at';-- return db.transaction(() => {-- const claimed = db.get('SELECT message_id FROM deploy_releases WHERE head = ?', [head]);++ return await db.tx(async () => {++ const claimed = await db.get('SELECT message_id FROM deploy_releases WHERE head = ?', [head]);if (claimed) {-- db.run(`UPDATE deploy_releases SET ${seenCol} = COALESCE(${seenCol}, ?), event_id = COALESCE(event_id, ?) WHERE head = ?`, [now, eventId, head]);++ await db.run(`UPDATE deploy_releases SET ${seenCol} = COALESCE(${seenCol}, ?), event_id = COALESCE(event_id, ?) WHERE head = ?`, [now, eventId, head]);return { duplicate: true, id: claimed.message_id };}-- const saved = persist(db, commits);-- db.run(`INSERT INTO deploy_releases (head, message_id, first_via, ${seenCol}, event_id, commit_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,++ const saved = await persist(db, commits);++ await db.run(`INSERT INTO deploy_releases (head, message_id, first_via, ${seenCol}, event_id, commit_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,[head, saved.id, via, now, eventId, commits.length, now]);return { duplicate: false, saved };});@@@ -118,13 +118,13 @@ function broadcast({ chatServer, saved, list, log = console }) {* bridge call) announces nothing.* @returns {{ announced: number, id?: number, duplicate?: boolean }}*/--function announceCommits({ db, chatServer, commits, log = console }) {++async function announceCommits({ db, chatServer, commits, log = console }) {const list = cleanCommits(commits);if (!list.length) return { announced: 0 };const head = list[0].hash;let r;-- try { r = claimRelease(db, { head, commits: list, via: 'bridge' }); }++ try { r = await claimRelease(db, { head, commits: list, via: 'bridge' }); }catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }if (r.duplicate) {log.log(`[Deploy notice] ${head.slice(0, 7)} already announced (card ${r.id}); the bridge copy changes nothing`);@@@ -155,10 +155,10 @@ function releaseFrom(event, { now = Date.now(), maxAgeMs = RELEASE_MAX_AGE_MS }* has committed. outcome: announced (a new card) | folded (into the last card) | duplicate:release* (the bridge, or an earlier event with this head, made the card already).*/--function applyReleaseEvent({ db, chatServer, event, now = Date.now(), maxAgeMs = RELEASE_MAX_AGE_MS, log = console }) {++async function applyReleaseEvent({ db, chatServer, event, now = Date.now(), maxAgeMs = RELEASE_MAX_AGE_MS, log = console }) {const rel = releaseFrom(event, { now, maxAgeMs });if (typeof rel === 'string') return rel;-- const r = claimRelease(db, { head: rel.head, commits: rel.commits, via: 'events', eventId: event.event_id, now });++ const r = await claimRelease(db, { head: rel.head, commits: rel.commits, via: 'events', eventId: event.event_id, now });const detail = { head: rel.head, message_id: r.duplicate ? r.id : r.saved.id, commits: rel.commits.length };if (r.duplicate) return { outcome: 'duplicate:release', detail };return { outcome: r.saved.folded ? 'folded' : 'announced', detail, after: () => broadcast({ chatServer, saved: r.saved, list: rel.commits, log }) };@@@ -26,8 +26,8 @@ const prefStores = require('../prefs/stores');* conversation, or being added to a group? When Network cannot answer, the defaults (yes) apply.*/async function dmSettingsOf(target) {-- const subject = (target && target.subject_id) || require('../db/database').subjectFor(target && target.id);-- return prefStores.settingsOf(prefStores.dm, subject);++ const subject = (target && target.subject_id) || await require('../db/database').subjectFor(target && target.id);++ return await prefStores.settingsOf(prefStores.dm, subject);}// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.@@@ -78,12 +78,12 @@ setInterval(() => {}, 300_000);// List conversations for current user--router.get('/conversations', (req, res) => {++router.get('/conversations', async (req, res) => {try {-- const conversations = dm.getConversations(req.user.id);++ const conversations = await dm.getConversations(req.user.id);// Attach participant info to each conversationfor (const conv of conversations) {-- conv.participants = dm.getParticipants(conv.id);++ conv.participants = await dm.getParticipants(conv.id);}res.json({ conversations });} catch (err) {@@@ -113,7 +113,7 @@ router.post('/conversations', startLimit, async (req, res) => {}// New account restriction-- const accountCheck = dm.isAccountTooNew(req.user.id, 5);++ const accountCheck = await dm.isAccountTooNew(req.user.id, 5);if (accountCheck.tooNew) {return res.status(403).json({ error: `Your account is too new to start conversations. Please wait ${accountCheck.minutesRemaining} more minute(s).` });}@@@ -122,15 +122,15 @@ router.post('/conversations', startLimit, async (req, res) => {try { await ctx.ensureUsers(participantIds); } catch { /* the projection answers below */ }for (const uid of participantIds) {if (uid === req.user.id) continue;-- const target = ctx.getUserById(uid);++ const target = await ctx.getUserById(uid);if (!target) return res.status(400).json({ error: 'User not found' });if (target.is_banned) return res.status(400).json({ error: 'Cannot message banned users' });-- if (dm.isBlockedEither(req.user.id, uid)) {++ if (await dm.isBlockedEither(req.user.id, uid)) {return res.status(403).json({ error: 'Cannot start a conversation with this user' });}// Their message settings: a new direct conversation, or a group, only if they accept one.const wants = await dmSettingsOf(target);-- if (participantIds.length === 2 && wants.new_conversations === 'nobody' && !dm.findDirectConversation(req.user.id, uid)) {++ if (participantIds.length === 2 && wants.new_conversations === 'nobody' && !await dm.findDirectConversation(req.user.id, uid)) {return res.status(403).json({ error: `${target.display_name || target.username} is not accepting new conversations`, code: 'dm.not_accepting' });}if (participantIds.length > 2 && wants.group_invites === false) {@@@ -141,14 +141,14 @@ router.post('/conversations', startLimit, async (req, res) => {let conversationId;if (participantIds.length === 2) {const otherId = participantIds.find(id => id !== req.user.id);-- conversationId = dm.getOrCreateDirect(req.user.id, otherId);++ conversationId = await dm.getOrCreateDirect(req.user.id, otherId);} else {const safeName = name ? String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100) : null;-- conversationId = dm.createConversation(req.user.id, participantIds, safeName);++ conversationId = await dm.createConversation(req.user.id, participantIds, safeName);}-- const conversation = dm.getConversation(conversationId);-- conversation.participants = dm.getParticipants(conversationId);++ const conversation = await dm.getConversation(conversationId);++ conversation.participants = await dm.getParticipants(conversationId);res.json({ conversation });} catch (err) {console.error('[DM] Create conversation error:', err.message);@@@ -157,15 +157,15 @@ router.post('/conversations', startLimit, async (req, res) => {});// Get conversation details--router.get('/conversations/:id', (req, res) => {++router.get('/conversations/:id', async (req, res) => {try {const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {++ if (!await dm.isParticipant(convId, req.user.id)) {return res.status(403).json({ error: 'Not a participant' });}-- const conversation = dm.getConversation(convId);++ const conversation = await dm.getConversation(convId);if (!conversation) return res.status(404).json({ error: 'Not found' });-- conversation.participants = dm.getParticipants(convId);++ conversation.participants = await dm.getParticipants(convId);res.json({ conversation });} catch (err) {console.error('[DM] Get conversation error:', err.message);@@@ -174,16 +174,16 @@ router.get('/conversations/:id', (req, res) => {});// Get messages (paginated)--router.get('/conversations/:id/messages', (req, res) => {++router.get('/conversations/:id/messages', async (req, res) => {try {const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {++ if (!await dm.isParticipant(convId, req.user.id)) {return res.status(403).json({ error: 'Not a participant' });}const limit = Math.min(parseInt(req.query.limit) || 50, 100);const before = parseInt(req.query.before) || null;const after = parseInt(req.query.after) || null;-- const messages = dm.getMessages(convId, limit, before, after).map(publicMessage);++ const messages = (await dm.getMessages(convId, limit, before, after)).map(publicMessage);res.json({ messages });} catch (err) {console.error('[DM] Get messages error:', err.message);@@@ -192,10 +192,10 @@ router.get('/conversations/:id/messages', (req, res) => {});// Send a message--router.post('/conversations/:id/messages', sendLimit, (req, res) => {++router.post('/conversations/:id/messages', sendLimit, async (req, res) => {try {const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {++ if (!await dm.isParticipant(convId, req.user.id)) {return res.status(403).json({ error: 'Not a participant' });}@@@ -205,11 +205,11 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {}// Block check for 1-on-1 conversations-- const conv = dm.getConversation(convId);++ const conv = await dm.getConversation(convId);if (conv && !conv.is_group) {-- const participants = dm.getParticipants(convId);++ const participants = await dm.getParticipants(convId);const other = participants.find(p => p.id !== req.user.id);-- if (other && dm.isBlockedEither(req.user.id, other.id)) {++ if (other && await dm.isBlockedEither(req.user.id, other.id)) {return res.status(403).json({ error: 'Cannot send messages in this conversation' });}}@@@ -219,11 +219,11 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {return res.status(400).json({ error: 'Message required' });}-- const msg = publicMessage(dm.sendMessage(convId, req.user.id, message));++ const msg = publicMessage(await dm.sendMessage(convId, req.user.id, message));if (!msg) return res.status(400).json({ error: 'Failed to send' });// Attach sender info-- const sender = ctx.getUserById(req.user.id);++ const sender = await ctx.getUserById(req.user.id);msg.username = sender?.username;msg.display_name = sender?.display_name;msg.avatar_url = sender?.avatar_url;@@@ -232,10 +232,10 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {// Real-time delivery via chat WebSockettry {const chatServer = require('./chat-server');-- const participants = dm.getParticipants(convId);++ const participants = await dm.getParticipants(convId);for (const p of participants) {if (p.id === req.user.id) continue; // skip sender-- chatServer.sendDm(p.id, {++ await chatServer.sendDm(p.id, {type: 'dm',conversation_id: convId,message: msg,@@@ -246,7 +246,7 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {// Push notifications to other participants (for when they're offline) — Live's// utils/notify pushes them to the Network; the preview stays generic there.try {-- const participants = dm.getParticipants(convId);++ const participants = await dm.getParticipants(convId);const otherIds = participants.filter(p => p.id !== req.user.id).map(p => p.id);if (otherIds.length) {ctx.effects.notifyDm({ recipient_ids: otherIds, sender_id: req.user.id, conversation_id: convId });@@@ -261,13 +261,13 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {});// Mark conversation read--router.post('/conversations/:id/read', readLimit, (req, res) => {++router.post('/conversations/:id/read', readLimit, async (req, res) => {try {const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {++ if (!await dm.isParticipant(convId, req.user.id)) {return res.status(403).json({ error: 'Not a participant' });}-- dm.markRead(convId, req.user.id);++ await dm.markRead(convId, req.user.id);// Clear matching DM notifications on the Network (through Live's utils/notify)try {@@@ -285,7 +285,7 @@ router.post('/conversations/:id/read', readLimit, (req, res) => {router.post('/conversations/:id/participants', manageLimit, async (req, res) => {try {const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {++ if (!await dm.isParticipant(convId, req.user.id)) {return res.status(403).json({ error: 'Not a participant' });}const { user_id } = req.body;@@@ -293,17 +293,19 @@ router.post('/conversations/:id/participants', manageLimit, async (req, res) =>// Validate target user exists, isn't banned, isn't blocked (accounts are Live's)try { await ctx.ensureUsers([user_id]); } catch { /* the projection answers below */ }-- const target = ctx.getUserById(user_id);++ const target = await ctx.getUserById(user_id);if (!target) return res.status(400).json({ error: 'User not found' });if (target.is_banned) return res.status(400).json({ error: 'Cannot add banned users' });// A private 1:1 stays private: adding a third person would hand them the whole history.-- const conv = dm.getConversation(convId);++ const conv = await dm.getConversation(convId);if (!conv || !conv.is_group) {return res.status(400).json({ error: 'Start a group conversation to add people' });}// Nobody already in the group should end up talking to someone they blocked (or who blocked them).-- const members = dm.getParticipants(convId) || [];-- if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {++ const members = await dm.getParticipants(convId) || [];++ let blocked = false;++ for (const p of members) if (await dm.isBlockedEither(p.user_id || p.id, user_id)) { blocked = true; break; }++ if (blocked) {return res.status(403).json({ error: 'Cannot add this user' });}if ((await dmSettingsOf(target)).group_invites === false) {@@@ -311,18 +313,18 @@ router.post('/conversations/:id/participants', manageLimit, async (req, res) =>}// Group size limit-- if (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {++ if (await dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {return res.status(400).json({ error: `Group is full (max ${MAX_GROUP_SIZE} members)` });}-- dm.addParticipant(convId, user_id);-- const participants = dm.getParticipants(convId);++ await dm.addParticipant(convId, user_id);++ const participants = await dm.getParticipants(convId);// Notify all participants about the new membertry {const chatServer = require('./chat-server');for (const p of participants) {-- chatServer.sendDm(p.id, {++ await chatServer.sendDm(p.id, {type: 'dm-participant-added',conversation_id: convId,user_id,@@@ -339,19 +341,19 @@ router.post('/conversations/:id/participants', manageLimit, async (req, res) =>});// Remove participant from group--router.delete('/conversations/:id/participants/:userId', manageLimit, (req, res) => {++router.delete('/conversations/:id/participants/:userId', manageLimit, async (req, res) => {try {const convId = parseInt(req.params.id);const targetUserId = parseInt(req.params.userId);-- if (!dm.isParticipant(convId, req.user.id)) {++ if (!await dm.isParticipant(convId, req.user.id)) {return res.status(403).json({ error: 'Not a participant' });}// Can only remove self (leave) or be the creator-- const conv = dm.getConversation(convId);++ const conv = await dm.getConversation(convId);if (targetUserId !== req.user.id && conv?.created_by !== req.user.id) {return res.status(403).json({ error: 'Only the creator can remove others' });}-- dm.removeParticipant(convId, targetUserId);++ await dm.removeParticipant(convId, targetUserId);res.json({ ok: true });} catch (err) {console.error('[DM] Remove participant error:', err.message);@@@ -360,17 +362,17 @@ router.delete('/conversations/:id/participants/:userId', manageLimit, (req, res)});// Rename group conversation--router.patch('/conversations/:id', manageLimit, (req, res) => {++router.patch('/conversations/:id', manageLimit, async (req, res) => {try {const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {++ if (!await dm.isParticipant(convId, req.user.id)) {return res.status(403).json({ error: 'Not a participant' });}let { name } = req.body;if (name) {name = String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100);}-- dm.renameConversation(convId, name || null);++ await dm.renameConversation(convId, name || null);res.json({ ok: true });} catch (err) {console.error('[DM] Rename error:', err.message);@@@ -379,9 +381,9 @@ router.patch('/conversations/:id', manageLimit, (req, res) => {});// Total unread count--router.get('/unread', (req, res) => {++router.get('/unread', async (req, res) => {try {-- const total = dm.getTotalUnread(req.user.id);++ const total = await dm.getTotalUnread(req.user.id);res.json({ unread: total });} catch (err) {res.status(500).json({ error: 'Failed' });@@@ -389,11 +391,11 @@ router.get('/unread', (req, res) => {});// Search users for new-message picker--router.get('/users/search', (req, res) => {++router.get('/users/search', async (req, res) => {try {const q = (req.query.q || '').trim();if (q.length < 2) return res.json({ users: [] });-- const users = dm.searchUsers(q, req.user.id);++ const users = await dm.searchUsers(q, req.user.id);res.json({ users });} catch (err) {res.status(500).json({ error: 'Failed' });@@@ -403,13 +405,13 @@ router.get('/users/search', (req, res) => {// ── Block & message management ───────────────────────────────// Block a user--router.post('/blocks/:userId', manageLimit, (req, res) => {++router.post('/blocks/:userId', manageLimit, async (req, res) => {try {const targetId = parseInt(req.params.userId);if (!targetId || targetId === req.user.id) {return res.status(400).json({ error: 'Invalid user' });}-- dm.blockUser(req.user.id, targetId);++ await dm.blockUser(req.user.id, targetId);res.json({ ok: true });} catch (err) {console.error('[DM] Block error:', err.message);@@@ -418,10 +420,10 @@ router.post('/blocks/:userId', manageLimit, (req, res) => {});// Unblock a user--router.delete('/blocks/:userId', manageLimit, (req, res) => {++router.delete('/blocks/:userId', manageLimit, async (req, res) => {try {const targetId = parseInt(req.params.userId);-- dm.unblockUser(req.user.id, targetId);++ await dm.unblockUser(req.user.id, targetId);res.json({ ok: true });} catch (err) {console.error('[DM] Unblock error:', err.message);@@@ -430,9 +432,9 @@ router.delete('/blocks/:userId', manageLimit, (req, res) => {});// List blocked users--router.get('/blocks', (req, res) => {++router.get('/blocks', async (req, res) => {try {-- const blocked = dm.getBlockedUsers(req.user.id);++ const blocked = await dm.getBlockedUsers(req.user.id);res.json({ blocked });} catch (err) {res.status(500).json({ error: 'Failed to load blocked users' });@@@ -440,24 +442,24 @@ router.get('/blocks', (req, res) => {});// Check if a specific user is blocked--router.get('/blocks/check/:userId', (req, res) => {++router.get('/blocks/check/:userId', async (req, res) => {try {const targetId = parseInt(req.params.userId);-- res.json({ blocked: dm.hasBlocked(req.user.id, targetId) });++ res.json({ blocked: await dm.hasBlocked(req.user.id, targetId) });} catch {res.json({ blocked: false });}});// Delete own message--router.delete('/conversations/:id/messages/:msgId', deleteLimit, (req, res) => {++router.delete('/conversations/:id/messages/:msgId', deleteLimit, async (req, res) => {try {const convId = parseInt(req.params.id);const msgId = parseInt(req.params.msgId);-- if (!dm.isParticipant(convId, req.user.id)) {++ if (!await dm.isParticipant(convId, req.user.id)) {return res.status(403).json({ error: 'Not a participant' });}-- const deleted = dm.deleteMessage(msgId, req.user.id);++ const deleted = await dm.deleteMessage(msgId, req.user.id);if (!deleted) return res.status(403).json({ error: 'Cannot delete this message' });res.json({ ok: true });} catch (err) {@@@ -23,7 +23,7 @@ const networkBlocks = require('./network-blocks');// ── Schema & Migrations ──────────────────────────────────────function ensureTables() {-- // The DM tables are created with the rest of Chat's schema (server/db/schema.sql).++ // The DM tables are created with the rest of Chat's schema (migrations/0001_initial.sql).db.getDb();}@@@ -33,8 +33,8 @@ function ensureTables() {* Find an existing 1-on-1 conversation between two users.* Returns conversation row or null.*/--function findDirectConversation(userIdA, userIdB) {-- return db.get(`++async function findDirectConversation(userIdA, userIdB) {++ return await db.get(`SELECT c.* FROM dm_conversations cJOIN dm_participants p1 ON p1.conversation_id = c.id AND p1.user_id = ?JOIN dm_participants p2 ON p2.conversation_id = c.id AND p2.user_id = ?@@@ -46,18 +46,18 @@ function findDirectConversation(userIdA, userIdB) {* Create a new conversation. Returns the new conversation id.* participantIds should include the creator.*/--function createConversation(createdBy, participantIds, name = null) {++async function createConversation(createdBy, participantIds, name = null) {const isGroup = participantIds.length > 2 ? 1 : 0;-- const result = db.run(++ const result = await db.run(`INSERT INTO dm_conversations (name, is_group, created_by) VALUES (?, ?, ?)`,[isGroup ? (name || null) : null, isGroup, createdBy]);const convId = result.lastInsertRowid;const insert = db.getDb().prepare(-- `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?)`++ `INSERT INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`);for (const uid of participantIds) {-- insert.run(convId, uid, db.subjectFor(uid));++ await insert.run(convId, uid, await db.subjectFor(uid));}return convId;}@@@ -65,35 +65,35 @@ function createConversation(createdBy, participantIds, name = null) {/*** Get or create a 1-on-1 conversation between two users.*/--function getOrCreateDirect(userIdA, userIdB) {-- const existing = findDirectConversation(userIdA, userIdB);++async function getOrCreateDirect(userIdA, userIdB) {++ const existing = await findDirectConversation(userIdA, userIdB);if (existing) return existing.id;-- return createConversation(userIdA, [userIdA, userIdB]);++ return await createConversation(userIdA, [userIdA, userIdB]);}/*** Add a participant to an existing group conversation.*/--function addParticipant(conversationId, userId) {-- db.run(-- `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?)`,-- [conversationId, userId, db.subjectFor(userId)]++async function addParticipant(conversationId, userId) {++ await db.run(++ `INSERT INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`,++ [conversationId, userId, await db.subjectFor(userId)]);// Mark as group if > 2 participants-- const count = db.get(++ const count = (await db.get(`SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,[conversationId]-- )?.c || 0;++ ))?.c || 0;if (count > 2) {-- db.run(`UPDATE dm_conversations SET is_group = 1 WHERE id = ?`, [conversationId]);++ await db.run(`UPDATE dm_conversations SET is_group = 1 WHERE id = ?`, [conversationId]);}}/*** Remove a participant from a group conversation.*/--function removeParticipant(conversationId, userId) {-- db.run(++async function removeParticipant(conversationId, userId) {++ await db.run(`DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,[conversationId, userId]);@@@ -102,8 +102,8 @@ function removeParticipant(conversationId, userId) {/*** Check if a user is a participant in a conversation.*/--function isParticipant(conversationId, userId) {-- return !!db.get(++async function isParticipant(conversationId, userId) {++ return !!await db.get(`SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,[conversationId, userId]);@@@ -112,15 +112,15 @@ function isParticipant(conversationId, userId) {/*** Rename a group conversation.*/--function renameConversation(conversationId, name) {-- db.run(`UPDATE dm_conversations SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [name, conversationId]);++async function renameConversation(conversationId, name) {++ await db.run(`UPDATE dm_conversations SET name = ?, updated_at = ov_now() WHERE id = ?`, [name, conversationId]);}/*** Get all conversations for a user with last message preview and unread count.*/--function getConversations(userId) {-- return db.all(`++async function getConversations(userId) {++ return await db.all(`SELECTc.id,c.name,@@@ -140,8 +140,8 @@ function getConversations(userId) {/*** Get participants of a conversation (with user profile info).*/--function getParticipants(conversationId) {-- return db.all(`++async function getParticipants(conversationId) {++ return await db.all(`SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_colorFROM dm_participants pJOIN ctx_users u ON u.id = p.user_id@@@ -152,8 +152,8 @@ function getParticipants(conversationId) {/*** Get a single conversation by id (with participant info for the requesting user).*/--function getConversation(conversationId) {-- return db.get(`SELECT * FROM dm_conversations WHERE id = ?`, [conversationId]) || null;++async function getConversation(conversationId) {++ return await db.get(`SELECT * FROM dm_conversations WHERE id = ?`, [conversationId]) || null;}// ── Message helpers ──────────────────────────────────────────@@@ -161,22 +161,22 @@ function getConversation(conversationId) {/*** Send a message in a conversation. Returns the message row.*/--function sendMessage(conversationId, senderId, text) {++async function sendMessage(conversationId, senderId, text) {if (!text || !text.trim()) return null;const trimmed = text.trim().slice(0, 2000); // max 2000 chars-- const senderSubject = db.subjectFor(senderId);-- return db.transaction(() => {-- const result = db.run(++ const senderSubject = await db.subjectFor(senderId);++ return await db.tx(async () => {++ const result = await db.run(`INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id) VALUES (?, ?, ?, ?)`,[conversationId, senderId, trimmed, senderSubject]);// Touch conversation updated_at-- db.run(`UPDATE dm_conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [conversationId]);++ await db.run(`UPDATE dm_conversations SET updated_at = ov_now() WHERE id = ?`, [conversationId]);// Auto-mark read for sender-- markRead(conversationId, senderId);++ await markRead(conversationId, senderId);// The event names who is in the conversation, never what was said.-- const participants = db.all('SELECT user_id, subject_id FROM dm_participants WHERE conversation_id = ?', [conversationId]);-- outbox.enqueue({++ const participants = await db.all('SELECT user_id, subject_id FROM dm_participants WHERE conversation_id = ?', [conversationId]);++ await outbox.enqueue({event_type: 'chat.dm.created',visibility: 'subject',actorSubject: senderSubject,@@@ -186,20 +186,20 @@ function sendMessage(conversationId, senderId, text) {conversation_id: Number(conversationId),sender_user_id: senderId,sender_subject: senderSubject,-- participants: participants.map((p) => ({ user_id: p.user_id, subject: p.subject_id || db.subjectFor(p.user_id) })),++ participants: (await Promise.all(participants.map(async (p) => ({ user_id: p.user_id, subject: p.subject_id || await db.subjectFor(p.user_id) })))),},});-- return db.get(`SELECT * FROM dm_messages WHERE id = ?`, [result.lastInsertRowid]);++ return await db.get(`SELECT * FROM dm_messages WHERE id = ?`, [result.lastInsertRowid]);});}/*** Get messages in a conversation (paginated, newest first).*/--function getMessages(conversationId, limit = 50, before = null, after = null) {++async function getMessages(conversationId, limit = 50, before = null, after = null) {if (after) {// Fetch messages newer than `after` id (for live polling)-- return db.all(`++ return await db.all(`SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_colorFROM dm_messages mJOIN ctx_users u ON u.id = m.sender_id@@@ -209,7 +209,7 @@ function getMessages(conversationId, limit = 50, before = null, after = null) {`, [conversationId, after, limit]);}if (before) {-- return db.all(`++ return await db.all(`SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_colorFROM dm_messages mJOIN ctx_users u ON u.id = m.sender_id@@@ -218,7 +218,7 @@ function getMessages(conversationId, limit = 50, before = null, after = null) {LIMIT ?`, [conversationId, before, limit]);}-- return db.all(`++ return await db.all(`SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_colorFROM dm_messages mJOIN ctx_users u ON u.id = m.sender_id@@@ -231,9 +231,9 @@ function getMessages(conversationId, limit = 50, before = null, after = null) {/*** Mark a conversation as read for a user (set last_read_at to now).*/--function markRead(conversationId, userId) {-- db.run(-- `UPDATE dm_participants SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = ? AND user_id = ?`,++async function markRead(conversationId, userId) {++ await db.run(++ `UPDATE dm_participants SET last_read_at = ov_now() WHERE conversation_id = ? AND user_id = ?`,[conversationId, userId]);}@@@ -241,14 +241,14 @@ function markRead(conversationId, userId) {/*** Get total unread message count across all conversations for a user.*/--function getTotalUnread(userId) {-- const row = db.get(`-- SELECT COALESCE(SUM(unread), 0) as total FROM (++async function getTotalUnread(userId) {++ const row = await db.get(`++ SELECT CAST(COALESCE(SUM(unread), 0) AS BIGINT) as total FROM (SELECT COUNT(*) as unreadFROM dm_messages mJOIN dm_participants p ON p.conversation_id = m.conversation_id AND p.user_id = ?WHERE m.created_at > p.last_read_at AND m.sender_id != ?-- )++ ) AS per_conversation`, [userId, userId]);return row?.total || 0;}@@@ -257,11 +257,11 @@ function getTotalUnread(userId) {* Search users by username/display_name for the "new message" user picker.* Excludes the requesting user.*/--function searchUsers(query, excludeUserId, limit = 10) {++async function searchUsers(query, excludeUserId, limit = 10) {if (!query || query.length < 2) return [];networkBlocks.ensureSchema();-- const me = db.subjectFor(excludeUserId) || '';-- return db.all(`++ const me = await db.subjectFor(excludeUserId) || '';++ return await db.all(`SELECT id, username, display_name, avatar_url, profile_colorFROM ctx_usersWHERE id != ? AND is_banned = 0@@@ -269,7 +269,7 @@ function searchUsers(query, excludeUserId, limit = 10) {AND id NOT IN (SELECT blocker_id FROM dm_blocks WHERE blocked_id = ?)AND COALESCE(subject_id, '') NOT IN (SELECT blocked_subject FROM network_blocks WHERE blocker_subject = ? AND active = 1)AND COALESCE(subject_id, '') NOT IN (SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1)-- AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE)++ AND (username ILIKE ? OR display_name ILIKE ?)LIMIT ?`, [excludeUserId, excludeUserId, excludeUserId, me, me, `%${query}%`, `%${query}%`, limit]);}@@@ -280,19 +280,19 @@ function searchUsers(query, excludeUserId, limit = 10) {* Check if either user has blocked the other (bidirectional): in Chat's dm_blocks, or on the network* (platform blocks, ./network-blocks.js, by Network subject).*/--function isBlockedEither(userIdA, userIdB) {-- return !!db.get(++async function isBlockedEither(userIdA, userIdB) {++ return !!await db.get(`SELECT 1 FROM dm_blocksWHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,[userIdA, userIdB, userIdB, userIdA]-- ) || networkBlocks.eitherBlockedUsers(userIdA, userIdB);++ ) || await networkBlocks.eitherBlockedUsers(userIdA, userIdB);}/*** Check if blocker has blocked blocked (one-directional).*/--function hasBlocked(blockerId, blockedId) {-- return !!db.get(++async function hasBlocked(blockerId, blockedId) {++ return !!await db.get(`SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,[blockerId, blockedId]);@@@ -301,18 +301,18 @@ function hasBlocked(blockerId, blockedId) {/*** Block a user.*/--function blockUser(blockerId, blockedId) {-- db.run(-- `INSERT OR IGNORE INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?)`,-- [blockerId, blockedId, db.subjectFor(blockerId)]++async function blockUser(blockerId, blockedId) {++ await db.run(++ `INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`,++ [blockerId, blockedId, await db.subjectFor(blockerId)]);}/*** Unblock a user.*/--function unblockUser(blockerId, blockedId) {-- db.run(++async function unblockUser(blockerId, blockedId) {++ await db.run(`DELETE FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,[blockerId, blockedId]);@@@ -321,8 +321,8 @@ function unblockUser(blockerId, blockedId) {/*** Get all users blocked by blockerId.*/--function getBlockedUsers(blockerId) {-- return db.all(`++async function getBlockedUsers(blockerId) {++ return await db.all(`SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, b.created_at as blocked_atFROM dm_blocks bJOIN ctx_users u ON u.id = b.blocked_id@@@ -334,18 +334,18 @@ function getBlockedUsers(blockerId) {/*** Delete a message (only the sender can delete their own message).*/--function deleteMessage(messageId, userId) {-- const msg = db.get(`SELECT * FROM dm_messages WHERE id = ?`, [messageId]);++async function deleteMessage(messageId, userId) {++ const msg = await db.get(`SELECT * FROM dm_messages WHERE id = ?`, [messageId]);if (!msg || msg.sender_id !== userId) return false;-- db.run(`DELETE FROM dm_messages WHERE id = ?`, [messageId]);++ await db.run(`DELETE FROM dm_messages WHERE id = ?`, [messageId]);return true;}/*** Get the number of participants in a conversation.*/--function getParticipantCount(conversationId) {-- const row = db.get(++async function getParticipantCount(conversationId) {++ const row = await db.get(`SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,[conversationId]);@@@ -399,8 +399,8 @@ function checkMessageSpam(text) {* Check if this message is a duplicate of the user's recent messages.* Returns true if the exact same text was sent within the last N seconds.*/--function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30) {-- const row = db.get(`++async function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30) {++ const row = await db.get(`SELECT 1 FROM dm_messagesWHERE conversation_id = ? AND sender_id = ? AND message = ?AND created_at > datetime('now', '-' || ? || ' seconds')@@@ -413,8 +413,8 @@ function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30)* Check if a user account is too new to send DMs (minimum account age).* Returns { tooNew: boolean, minutesRemaining: number }*/--function isAccountTooNew(userId, minMinutes = 5) {-- const user = db.get(`SELECT created_at FROM ctx_users WHERE id = ?`, [userId]);++async function isAccountTooNew(userId, minMinutes = 5) {++ const user = await db.get(`SELECT created_at FROM ctx_users WHERE id = ?`, [userId]);if (!user || !user.created_at) return { tooNew: false, minutesRemaining: 0 };const created = new Date(user.created_at.endsWith('Z') ? user.created_at : user.created_at + 'Z');const ageMs = Date.now() - created.getTime();@@@ -190,9 +190,9 @@ const DEFAULT_EMOTES = [// ROUTES// ══════════════════════════════════════════════════════════════--router.get('/global', (req, res) => {++router.get('/global', async (req, res) => {try {-- const emotes = db.getGlobalEmotes().map((e) => ({++ const emotes = (await db.getGlobalEmotes()).map((e) => ({id: `custom-${e.id}`,code: e.code,url: fileUrl(e),@@@ -208,10 +208,10 @@ router.get('/global', (req, res) => {}});--router.get('/channel/:userId', (req, res) => {++router.get('/channel/:userId', async (req, res) => {try {const userId = parseInt(req.params.userId);-- const emotes = db.getChannelEmotes(userId).map((e) => ({++ const emotes = (await db.getChannelEmotes(userId)).map((e) => ({id: `custom-${e.id}`,emote_id: e.id,code: e.code,@@@ -230,9 +230,9 @@ router.get('/channel/:userId', (req, res) => {}});--router.get('/mine', requireAuth, (req, res) => {++router.get('/mine', requireAuth, async (req, res) => {try {-- const emotes = db.getEmotesByUser(req.user.id).map((e) => ({++ const emotes = (await db.getEmotesByUser(req.user.id)).map((e) => ({id: e.id,code: e.code,url: fileUrl(e),@@@ -242,7 +242,7 @@ router.get('/mine', requireAuth, (req, res) => {is_global: !!e.is_global,created_at: e.created_at,}));-- res.json({ emotes, count: db.countChannelEmotes(req.user.id), max: config.emotes.maxPerChannel });++ res.json({ emotes, count: await db.countChannelEmotes(req.user.id), max: config.emotes.maxPerChannel });} catch (err) {res.status(500).json({ error: 'Failed to load emotes' });}@@@ -275,25 +275,25 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {const channel = await ctx.ensureChannelForUser(channelOwnerId);if (!channel) return res.status(404).json({ error: 'Channel not found' });await ctx.ensurePolicy(channel.id);-- const settings = ctx.getChannelModerationSettings(channel.id);-- const isMod = permissions.canModerateChannel(req.user, channel.id);++ const settings = await ctx.getChannelModerationSettings(channel.id);++ const isMod = await permissions.canModerateChannel(req.user, channel.id);if (!settings.custom_emotes_enabled && !isMod) {return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });}if (settings.uploads_mods_only && !isMod) {return res.status(403).json({ error: 'Only channel mods can upload emotes here.' });}-- if (db.countChannelEmotes(channelOwnerId) >= config.emotes.maxPerChannel) {++ if (await db.countChannelEmotes(channelOwnerId) >= config.emotes.maxPerChannel) {return res.status(400).json({ error: `This channel is full (${config.emotes.maxPerChannel} emotes max) — the streamer needs to remove some first.` });}-- if (db.getChannelEmoteByCode(channelOwnerId, code)) {++ if (await db.getChannelEmoteByCode(channelOwnerId, code)) {return res.status(409).json({ error: `This channel already has an emote named "${code}".` });}} else {-- if (db.countChannelEmotes(req.user.id) >= config.emotes.maxPerChannel) {++ if (await db.countChannelEmotes(req.user.id) >= config.emotes.maxPerChannel) {return res.status(400).json({ error: `Your channel is full (${config.emotes.maxPerChannel} emotes max) — remove some to add more.` });}-- const existing = db.get('SELECT id FROM emotes WHERE user_id = ? AND code = ? AND channel_owner_id IS NULL', [req.user.id, code]);++ const existing = await db.get('SELECT id FROM emotes WHERE user_id = ? AND code = ? AND channel_owner_id IS NULL', [req.user.id, code]);if (existing) return res.status(409).json({ error: `You already have an emote named "${code}"` });}@@@ -305,13 +305,13 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {let sizeMin = 25, sizeMax = 400;if (channelOwnerId) {const channel = await ctx.ensureChannelForUser(channelOwnerId);-- const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;++ const st = channel ? await ctx.getChannelModerationSettings(channel.id) : null;if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }}const size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));const uploaded = await media.uploadObject(req.file.buffer, { filename: req.file.originalname, mimeType: req.file.mimetype });-- const result = db.createEmote({++ const result = await db.createEmote({user_id: req.user.id,code,url: uploaded.url,@@@ -326,7 +326,7 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {});if (isChannelUpload) {-- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'emotes-updated' }); } catch { /* */ }++ try { await require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'emotes-updated' }); } catch { /* */ }}res.json({emote: {@@@ -361,13 +361,13 @@ router.use((err, req, res, next) => {router.patch('/:id', requireAuth, async (req, res) => {try {-- const emote = db.getEmoteById(req.params.id);++ const emote = await db.getEmoteById(req.params.id);if (!emote) return res.status(404).json({ error: 'Emote not found' });let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');if (!allowed) {const ownerId = emote.channel_owner_id || emote.user_id;const channel = await ctx.ensureChannelForUser(ownerId);-- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;++ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;}if (!allowed) return res.status(403).json({ error: 'Not your emote' });@@@ -381,7 +381,7 @@ router.patch('/:id', requireAuth, async (req, res) => {return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });}const scopeOwner = emote.channel_owner_id || emote.user_id;-- const clash = (db.getChannelEmotes(scopeOwner) || [])++ const clash = (await db.getChannelEmotes(scopeOwner) || []).find((e) => e.id !== emote.id && String(e.code).toLowerCase() === code.toLowerCase());if (clash) return res.status(409).json({ error: `An emote named "${clash.code}" already exists in this channel.` });patch.code = code;@@@ -390,7 +390,7 @@ router.patch('/:id', requireAuth, async (req, res) => {let sizeMin = 25, sizeMax = 400;const ownerId = emote.channel_owner_id || emote.user_id;const channel = await ctx.ensureChannelForUser(ownerId);-- const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;++ const st = channel ? await ctx.getChannelModerationSettings(channel.id) : null;if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }patch.size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));}@@@ -398,12 +398,12 @@ router.patch('/:id', requireAuth, async (req, res) => {return res.status(400).json({ error: 'Nothing to change' });}-- db.updateEmote(emote.id, patch);++ await db.updateEmote(emote.id, patch);if (patch.code && patch.code !== emote.code) {-- try { db.updateChannelSoundEmoteRefs(emote.channel_owner_id || emote.user_id, emote.code, patch.code); } catch { /* */ }++ try { await db.updateChannelSoundEmoteRefs(emote.channel_owner_id || emote.user_id, emote.code, patch.code); } catch { /* */ }}if (emote.channel_owner_id) {-- try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }++ try { await require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }}res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });} catch (err) {@@@ -413,20 +413,20 @@ router.patch('/:id', requireAuth, async (req, res) => {router.delete('/:id', requireAuth, async (req, res) => {try {-- const emote = db.getEmoteById(req.params.id);++ const emote = await db.getEmoteById(req.params.id);if (!emote) return res.status(404).json({ error: 'Emote not found' });let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');if (!allowed) {const ownerId = emote.channel_owner_id || emote.user_id;const channel = await ctx.ensureChannelForUser(ownerId);-- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;++ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;}if (!allowed) return res.status(403).json({ error: 'Not your emote' });-- db.deleteEmote(emote.id);++ await db.deleteEmote(emote.id);await media.deleteObject(emote.media_asset_id);if (emote.channel_owner_id) {-- try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }++ try { await require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }}res.json({ message: 'Emote deleted' });} catch (err) {@@@ -575,7 +575,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {let globalCustom = [];let channelEmotes = [];if (sources.custom !== false) {-- globalCustom = db.getGlobalEmotes().map((e) => ({++ globalCustom = (await db.getGlobalEmotes()).map((e) => ({id: `custom-${e.id}`,code: e.code,url: fileUrl(e),@@@ -588,7 +588,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {}));if (streamUserId) {-- channelEmotes = db.getChannelEmotes(streamUserId).filter((e) => !e.is_global).map((e) => ({++ channelEmotes = (await db.getChannelEmotes(streamUserId)).filter((e) => !e.is_global).map((e) => ({id: `custom-${e.id}`,emote_id: e.id,code: e.code,@@@ -643,7 +643,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {let emoteScale = 100;if (channel) {-- const st = ctx.getChannelModerationSettings(channel.id);++ const st = await ctx.getChannelModerationSettings(channel.id);if (st && st.emote_scale) emoteScale = Number(st.emote_scale) || 100;}@@@ -76,8 +76,8 @@ function baseSql(r, { channelUsername } = {}) {return { sql: CHANNEL_SELECT, params: [r.userId] };}--function tipId() {-- try { const r = db.get('SELECT MAX(id) AS id FROM chat_messages'); return (r && r.id) || 0; } catch { return Date.now(); }++async function tipId() {++ try { const r = await db.get('SELECT MAX(id) AS id FROM chat_messages'); return (r && r.id) || 0; } catch { return Date.now(); }}const latestIdOf = (rows) => rows.reduce((m, x) => (x && x.id > m ? x.id : m), 0);@@@ -94,37 +94,37 @@ function memoSet(key, value) {if (memo.size > 64) { const oldest = memo.keys().next().value; memo.delete(oldest); }}--function readPage(r, { limit, before, channelUsername }) {++async function readPage(r, { limit, before, channelUsername }) {const { sql, params } = baseSql(r, { channelUsername });let q = sql;if (before) { q += ' AND cm.timestamp < ?'; params.push(before); }// The page order is the primary key alone (plan T3 decision 2): ids are monotonic in insert order,// so this is the same order as before, and idx_chat_page_live serves it without a sort on PostgreSQL.q += ' ORDER BY cm.id DESC LIMIT ?'; params.push(limit);-- return db.all(q, params).reverse();++ return (await db.all(q, params)).reverse();}--function readDelta(r, { afterId, limit, channelUsername }) {++async function readDelta(r, { afterId, limit, channelUsername }) {const { sql, params } = baseSql(r, { channelUsername });// One more than asked: if it comes back, the gap is bigger than the client can splice in.const q = `${sql} AND cm.id > ? ORDER BY cm.id ASC LIMIT ?`;params.push(afterId, limit + 1);-- return db.all(q, params);++ return await db.all(q, params);}/*** Newest rows of a room, oldest→newest.* @returns {{ messages: object[], latest_id: number }}*/--function page(room, { limit, before, channelUsername, decorate } = {}) {++async function page(room, { limit, before, channelUsername, decorate } = {}) {const r = parseRoom(room);const lim = clampLimit(limit);// The newest row id is part of the memo key, so a memoised page can never miss a message:// one primary-key lookup instead of the five-table join for every repeat reader in the window.-- const key = !before ? `${room}|${lim}|${channelUsername || ''}|${tipId()}` : null;++ const key = !before ? `${room}|${lim}|${channelUsername || ''}|${await tipId()}` : null;let rows = key ? memoGet(key) : null;if (!rows) {-- rows = readPage(r, { limit: lim, before, channelUsername });++ rows = await readPage(r, { limit: lim, before, channelUsername });if (key) memoSet(key, rows);}const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;@@@ -136,15 +136,15 @@ function page(room, { limit, before, channelUsername, decorate } = {}) {* missing; take a fresh page instead of splicing.* @returns {{ messages: object[], latest_id: number, complete: boolean }}*/--function delta(room, { afterId, limit, channelUsername, decorate } = {}) {++async function delta(room, { afterId, limit, channelUsername, decorate } = {}) {const r = parseRoom(room);const lim = clampLimit(limit, 200);const after = Math.max(0, parseInt(afterId, 10) || 0);-- let rows = readDelta(r, { afterId: after, limit: lim, channelUsername });++ let rows = await readDelta(r, { afterId: after, limit: lim, channelUsername });const complete = rows.length <= lim;if (!complete) rows = rows.slice(0, lim);const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;-- return { messages, latest_id: latestIdOf(rows) || after, complete, deleted_ids: deletedIds(room, after) };++ return { messages, latest_id: latestIdOf(rows) || after, complete, deleted_ids: await deletedIds(room, after) };}/**@@@ -155,7 +155,7 @@ function delta(room, { afterId, limit, channelUsername, decorate } = {}) {* @returns {number[]} oldest→newest*/const DELETED_WINDOW = 2 * MAX_LIMIT;--function deletedIds(room, afterId) {++async function deletedIds(room, afterId) {const after = Math.max(0, parseInt(afterId, 10) || 0);if (!after) return [];const m = /^stream:(\d+)$/.exec(String(room || ''));@@@ -164,11 +164,11 @@ function deletedIds(room, afterId) {: r.kind === 'channel' ? { where: 'channel_user_id = ?', params: [r.userId] }: { where: 'stream_id = ?', params: [r.streamId] };// Visible = what page()/delta() return; everything else in the window is reported.-- return db.all(`SELECT id FROM (++ return (await db.all(`SELECT id FROM (SELECT id, is_deleted, auto_delete_at FROM chat_messages-- WHERE ${scope.where} AND id <= ? ORDER BY timestamp DESC, id DESC LIMIT ?)-- WHERE NOT (is_deleted = 0 AND (auto_delete_at IS NULL OR COALESCE(datetime(auto_delete_at) > datetime('now'), 0)))-- ORDER BY id`, [...scope.params, after, DELETED_WINDOW]).map((x) => x.id);++ WHERE ${scope.where} AND id <= ? ORDER BY id DESC LIMIT ?) AS recent++ WHERE NOT (is_deleted = 0 AND (auto_delete_at IS NULL OR COALESCE(datetime(auto_delete_at) > datetime('now'), false)))++ ORDER BY id`, [...scope.params, after, DELETED_WINDOW])).map((x) => x.id);}/** Drop memoised pages for a room (or all rooms). Call from write paths when a tier is added. */@@@ -24,12 +24,12 @@ const serviceAuth = require('../net/service-auth');const router = express.Router();const guard = serviceAuth.guard('chat.moderation.read');--router.get('/channels/:channelId', guard, (req, res) => {++router.get('/channels/:channelId', guard, async (req, res) => {try {const channelId = parseInt(req.params.channelId, 10);if (!channelId) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });-- const settings = db.getChannelModerationSettingsRow(channelId) || ctx.defaultModerationSettings(channelId);-- const moderator_ids = db.getChannelModeratorIds(channelId);++ const settings = await db.getChannelModerationSettingsRow(channelId) || ctx.defaultModerationSettings(channelId);++ const moderator_ids = await db.getChannelModeratorIds(channelId);res.json({ ok: true, settings, moderator_ids });} catch (err) {console.error('[Moderation] channel read:', err.message);@@@ -37,11 +37,11 @@ router.get('/channels/:channelId', guard, (req, res) => {}});--router.get('/users/:userId/channels', guard, (req, res) => {++router.get('/users/:userId/channels', guard, async (req, res) => {try {const userId = parseInt(req.params.userId, 10);if (!userId) return res.status(400).json({ ok: false, error: 'Invalid user ID' });-- const channels = db.getChannelsByModerator(userId).map((c) => ({++ const channels = (await db.getChannelsByModerator(userId)).map((c) => ({channel_id: Number(c.channel_id),title: c.title == null ? null : String(c.title),owner_user_id: c.owner_user_id == null ? null : Number(c.owner_user_id),@@@ -54,13 +54,13 @@ router.get('/users/:userId/channels', guard, (req, res) => {}});--router.get('/channels/:channelId/emote-count', guard, (req, res) => {++router.get('/channels/:channelId/emote-count', guard, async (req, res) => {try {const raw = parseInt(req.params.channelId, 10);if (!raw) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });// A channels.id resolves to its owner's user id (the emotes' key); a user id is used as it is.-- const ownerId = (ctx.getChannelById(raw) || {}).user_id || raw;-- res.json({ ok: true, count: db.countChannelEmotes(ownerId) });++ const ownerId = (await ctx.getChannelById(raw) || {}).user_id || raw;++ res.json({ ok: true, count: await db.countChannelEmotes(ownerId) });} catch (err) {console.error('[Moderation] emote count:', err.message);res.status(500).json({ ok: false, error: 'Failed to count emotes' });@@@ -21,21 +21,9 @@ const { validate } = require('openvibe-contracts');const db = require('../db/database');const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--let ready = false;--function ensureSchema() {-- if (ready) return;-- db.getDb().exec(`CREATE TABLE IF NOT EXISTS network_blocks (-- blocker_subject TEXT NOT NULL,-- blocked_subject TEXT NOT NULL,-- active INTEGER NOT NULL,-- revision INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- PRIMARY KEY (blocker_subject, blocked_subject)-- );-- CREATE INDEX IF NOT EXISTS idx_network_blocks_blocked ON network_blocks(blocked_subject, active);`);-- ready = true;--}++// The table is in migrations/0001_initial.sql (PostgreSQL); nothing is created at runtime. Kept for callers.++function ensureSchema() {}/** The payload of a network.block.changed envelope from Network, or null when it is not one. */function payloadOf(event) {@@@ -46,48 +34,48 @@ function payloadOf(event) {}/** Apply one change when it is newer than what is kept for its pair. → true when the projection moved. */--function apply(p, now = Date.now()) {++async function apply(p, now = Date.now()) {ensureSchema();-- const r = db.run(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)++ const r = await db.run(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_atWHERE excluded.revision > network_blocks.revision`, [p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now]);return r.changes > 0;}/** Did `blocker` block `blocked` (subjects)? */--function hasBlocked(blocker, blocked) {++async function hasBlocked(blocker, blocked) {if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || ''))) return false;ensureSchema();-- return !!db.get('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1', [blocker, blocked]);++ return !!await db.get('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1', [blocker, blocked]);}/** Did either of two subjects block the other? */--function eitherBlocked(a, b) {-- return hasBlocked(a, b) || hasBlocked(b, a);++async function eitherBlocked(a, b) {++ return await hasBlocked(a, b) || await hasBlocked(b, a);}/** The same, for two Live user ids (through their Network subjects). */--function eitherBlockedUsers(userIdA, userIdB) {-- return eitherBlocked(db.subjectFor(userIdA), db.subjectFor(userIdB));++async function eitherBlockedUsers(userIdA, userIdB) {++ return await eitherBlocked(await db.subjectFor(userIdA), await db.subjectFor(userIdB));}/** The subjects who blocked `blocked` (a subject), active blocks only. One indexed read. */--function blockersOf(blocked) {++async function blockersOf(blocked) {if (!SUBJECT_RE.test(String(blocked || ''))) return [];ensureSchema();-- return db.all('SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1', [blocked]).map((r) => r.blocker_subject);++ return (await db.all('SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1', [blocked])).map((r) => r.blocker_subject);}/** The Live user ids a signed-in reader blocked (through their subjects, the ctx_users projection). */--function blockedUserIds(user) {-- const subject = user && (user.subject_id || db.subjectFor(user.id));++async function blockedUserIds(user) {++ const subject = user && (user.subject_id || await db.subjectFor(user.id));if (!SUBJECT_RE.test(String(subject || ''))) return new Set();ensureSchema();-- return new Set(db.all(`SELECT u.id FROM network_blocks b JOIN ctx_users u ON u.subject_id = b.blocked_subject-- WHERE b.blocker_subject = ? AND b.active = 1`, [subject]).map((r) => Number(r.id)));++ return new Set((await db.all(`SELECT u.id FROM network_blocks b JOIN ctx_users u ON u.subject_id = b.blocked_subject++ WHERE b.blocker_subject = ? AND b.active = 1`, [subject])).map((r) => Number(r.id)));}--/** For tests: forget that the table was created (the table stays). */--function _reset() { ready = false; }++/** For tests (nothing is cached). */++function _reset() {}module.exports = { ensureSchema, payloadOf, apply, hasBlocked, eitherBlocked, eitherBlockedUsers, blockersOf, blockedUserIds, _reset };@@@ -25,10 +25,10 @@ const { limits } = require('../net/actor-limits');* their own view: network-blocks.js). Pages and cursor reads alike; latest_id and deleted_ids stay* the room's, so the reader's cursor moves on as everyone's does. Such answers are per reader.*/--function withoutBlocked(req, res, rows) {++async function withoutBlocked(req, res, rows) {if (!req.user) return rows;res.set('Cache-Control', 'private, no-store');-- const hidden = networkBlocks.blockedUserIds(req.user);++ const hidden = await networkBlocks.blockedUserIds(req.user);if (!hidden.size) return rows;return rows.filter((m) => !(m.user_id && hidden.has(Number(m.user_id))));}@@@ -48,9 +48,9 @@ const exportLimit = limits('chat.export', { minute: 5, hour: 30 });// A staff member viewing, searching or exporting someone else's chat logs is recorded like any other// moderation action (moderation_actions → chat.moderation.action → the network audit log, ADR-022).// A person reading their own lines, or a streamer their own stream's, is not.--function auditLogAccess(req, action_type, { scope_type = 'site', scope_id = null, target_user_id = null, details = {} } = {}) {++async function auditLogAccess(req, action_type, { scope_type = 'site', scope_id = null, target_user_id = null, details = {} } = {}) {try {-- db.logModerationAction({ scope_type, scope_id, actor_user_id: req.user.id, target_user_id, action_type, details });++ await db.logModerationAction({ scope_type, scope_id, actor_user_id: req.user.id, target_user_id, action_type, details });} catch (err) {console.warn('[Chat] log access audit failed:', err.message);}@@@ -246,16 +246,16 @@ router.get('/gif/search', optionalAuth, gifLimit, async (req, res) => {* Each row must have reply_to_id (from DB). Adds a reply_to object* with { id, username, user_id, message } for the parent message.*/--function hydrateReplies(messages) {++async function hydrateReplies(messages) {const replyIds = [...new Set(messages.map(m => m.reply_to_id).filter(Boolean))];if (!replyIds.length) return messages;const placeholders = replyIds.map(() => '?').join(',');-- const parents = db.all(++ const parents = await db.all(`SELECT id, username, user_id, messageFROM chat_messagesWHERE id IN (${placeholders})AND is_deleted = 0-- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,++ AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > ov_now())`,replyIds);const parentMap = new Map(parents.map(p => [p.id, p]));@@@ -313,7 +313,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hourconst replyToId = req.body.reply_to_id ? parseInt(req.body.reply_to_id) : null;let replyTo = null;if (replyToId) {-- const parent = db.getChatMessageById(replyToId);++ const parent = await db.getChatMessageById(replyToId);if (parent && !parent.is_deleted) {replyTo = {id: parent.id,@@@ -353,7 +353,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour// Save to databaselet savedId = null;try {-- const result = db.saveChatMessage({++ const result = await db.saveChatMessage({stream_id: null,user_id: req.user.id,anon_id: null,@@@ -372,7 +372,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hourif (replyTo) chatMsg.reply_to = replyTo;// Broadcast to all global chat clients-- chatServer.broadcastGlobal(chatMsg);++ await chatServer.broadcastGlobal(chatMsg);res.json({ ok: true });} catch (err) {console.error('[Chat] REST send error:', err.message);@@@ -381,7 +381,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour});// ── Search Chat Messages (admin or self) ─────────────────────--router.get('/search', requireAuth, searchLimit, (req, res) => {++router.get('/search', requireAuth, searchLimit, async (req, res) => {try {const limit = Math.min(parseInt(req.query.limit || '50'), 200);const offset = parseInt(req.query.offset || '0');@@@ -392,11 +392,11 @@ router.get('/search', requireAuth, searchLimit, (req, res) => {// Admin / global_mod can search anyone; others search only their ownconst effectiveUserId = permissions.canViewOtherUserLogs(req.user) ? userId : req.user.id;-- const result = db.searchChatMessages({++ const result = await db.searchChatMessages({query, userId: effectiveUserId, streamId, limit, offset,});if (effectiveUserId !== req.user.id) {-- auditLogAccess(req, 'chat_log_search', { scope_type: streamId ? 'stream' : 'site', scope_id: streamId, target_user_id: effectiveUserId,++ await auditLogAccess(req, 'chat_log_search', { scope_type: streamId ? 'stream' : 'site', scope_id: streamId, target_user_id: effectiveUserId,details: { query: String(query).slice(0, 100), results: result.messages.length } });}publicRows(result.messages);@@@ -408,7 +408,7 @@ router.get('/search', requireAuth, searchLimit, (req, res) => {});// ── User Chat History ────────────────────────────────────────--router.get('/user/:userId/history', requireAuth, (req, res) => {++router.get('/user/:userId/history', requireAuth, async (req, res) => {try {const userId = parseInt(req.params.userId);const limit = Math.min(parseInt(req.query.limit || '50'), 200);@@@ -419,8 +419,8 @@ router.get('/user/:userId/history', requireAuth, (req, res) => {return res.status(403).json({ error: 'Access denied' });}-- const result = db.getUserChatHistory(userId, limit, offset);-- if (userId !== req.user.id && offset === 0) auditLogAccess(req, 'chat_log_view', { target_user_id: userId, details: { total: result.total } });++ const result = await db.getUserChatHistory(userId, limit, offset);++ if (userId !== req.user.id && offset === 0) await auditLogAccess(req, 'chat_log_view', { target_user_id: userId, details: { total: result.total } });publicRows(result.messages);res.json(result);} catch (err) {@@@ -442,9 +442,9 @@ router.get('/user/:username/profile', optionalAuth, async (req, res) => {});// ── Chat-relay (external) user info: join date (first message) etc. ──--router.get('/relay-user/:platform/:username', optionalAuth, (req, res) => {++router.get('/relay-user/:platform/:username', optionalAuth, async (req, res) => {try {-- const r = db.getRelayUser(req.params.platform, req.params.username);++ const r = await db.getRelayUser(req.params.platform, req.params.username);res.json({ relayUser: r || null });} catch (err) {res.status(500).json({ error: 'Failed to get relay user' });@@@ -459,14 +459,14 @@ router.get('/anon/:anonId', optionalAuth, async (req, res) => {// When the number was assigned is Live's (anon_ip_mappings); the chat counts are ours.const num = db.anonSubjectId(anonId);const firstSeen = num ? await ctx.anonFirstSeenByNum(num) : null;-- res.json({ anon: db.getAnonMeta(anonId, firstSeen) });++ res.json({ anon: await db.getAnonMeta(anonId, firstSeen) });} catch (err) {res.status(500).json({ error: 'Failed to get anon info' });}});// An anonymous chatter's chat-message history — same gate as native logs.--router.get('/anon/:anonId/logs', requireAuth, (req, res) => {++router.get('/anon/:anonId/logs', requireAuth, async (req, res) => {try {if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });const anonId = String(req.params.anonId || '');@@@ -474,7 +474,7 @@ router.get('/anon/:anonId/logs', requireAuth, (req, res) => {const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);const query = String(req.query.q || '').trim();-- const r = db.getAnonChatHistory(anonId, { limit, offset, query });++ const r = await db.getAnonChatHistory(anonId, { limit, offset, query });res.json(r);} catch (err) {res.status(500).json({ error: 'Failed to get anon user logs' });@@@ -482,13 +482,13 @@ router.get('/anon/:anonId/logs', requireAuth, (req, res) => {});// A relay (external-platform) user's chat-message history — same gate as native logs.--router.get('/relay-user/:platform/:username/logs', requireAuth, (req, res) => {++router.get('/relay-user/:platform/:username/logs', requireAuth, async (req, res) => {try {if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);const query = String(req.query.q || '').trim();-- const r = db.getRelayUserChatHistory(req.params.platform, req.params.username, { limit, offset, query });++ const r = await db.getRelayUserChatHistory(req.params.platform, req.params.username, { limit, offset, query });res.json(r);} catch (err) {res.status(500).json({ error: 'Failed to get relay user logs' });@@@ -513,11 +513,11 @@ router.get('/global/history', optionalAuth, async (req, res) => {// after_id = cursor read: only what the caller has not seen (oldest→newest, PK range scan).// Anything else = a page: the newest rows, optionally before a timestamp.const out = afterId != null-- ? historyStore.delta('global', { afterId, limit, channelUsername })-- : historyStore.page('global', { limit, before, channelUsername });-- out.messages = withoutBlocked(req, res, out.messages);++ ? await historyStore.delta('global', { afterId, limit, channelUsername })++ : await historyStore.page('global', { limit, before, channelUsername });++ out.messages = await withoutBlocked(req, res, out.messages);await loadDecor(out.messages);-- out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));++ out.messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(out.messages.map((x) => ({ ...x })))));res.json(out);} catch (err) {res.status(500).json({ error: 'Failed to get global chat history' });@@@ -525,7 +525,7 @@ router.get('/global/history', optionalAuth, async (req, res) => {});// ── Chat Replay (for VOD/clip playback sync) ────────────────--router.get('/:streamId/replay', optionalAuth, (req, res) => {++router.get('/:streamId/replay', optionalAuth, async (req, res) => {try {const streamId = parseInt(req.params.streamId);if (!streamId) return res.status(400).json({ error: 'Invalid stream ID' });@@@ -533,7 +533,7 @@ router.get('/:streamId/replay', optionalAuth, (req, res) => {const from = req.query.from || null; // ISO timestampconst to = req.query.to || null; // ISO timestamp-- const messages = publicRows(db.getChatReplay(streamId, from, to));++ const messages = publicRows(await db.getChatReplay(streamId, from, to));res.json({ messages });} catch (err) {res.status(500).json({ error: 'Failed to get chat replay' });@@@ -580,7 +580,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {LEFT JOIN ctx_users bu ON s.user_id = bu.idLEFT JOIN ctx_users cu ON cm.channel_user_id = cu.idWHERE cm.channel_user_id = ? AND cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;params.push(broadcasterId);} else {sql = `${select}@@@ -591,7 +591,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {LEFT JOIN ctx_users bu ON s.user_id = bu.idLEFT JOIN ctx_users cu ON cm.channel_user_id = cu.idWHERE cm.stream_id = ? AND cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;params.push(req.params.streamId);}@@@ -610,13 +610,13 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {params.push(limit);}-- let rows = db.all(sql, params);++ let rows = await db.all(sql, params);let complete = true;if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);-- rows = withoutBlocked(req, res, rows);++ rows = await withoutBlocked(req, res, rows);await loadDecor(rows);-- const messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(rows)));++ const messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(rows)));// Currently-live slots for this broadcaster — lets the client render a// "hop between live streams" affordance.@@@ -624,9 +624,9 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {let channel = null;if (broadcasterId) {try {-- const owner = ctx.getUserById(broadcasterId);++ const owner = await ctx.getUserById(broadcasterId);channel = owner ? owner.username : null;-- liveSlots = (ctx.getManagedStreamsByUserId(broadcasterId) || [])++ liveSlots = (await ctx.getManagedStreamsByUserId(broadcasterId) || []).filter(ms => ms.is_currently_live).map(ms => ({managed_stream_id: ms.id,@@@ -637,7 +637,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {} catch { /* non-critical */ }}// A cursor read also names the rows at or under the cursor deleted since (history-store.js).-- const deleted = afterId != null ? { deleted_ids: historyStore.deletedIds(spanStreamer ? `channel:${broadcasterId}` : `stream:${parseInt(req.params.streamId, 10) || 0}`, afterId) } : {};++ const deleted = afterId != null ? { deleted_ids: await historyStore.deletedIds(spanStreamer ? `channel:${broadcasterId}` : `stream:${parseInt(req.params.streamId, 10) || 0}`, afterId) } : {};res.json({messages, latest_id, complete, ...deleted, liveSlots, channel,activeStreamId: parseInt(req.params.streamId) || null,@@@ -657,16 +657,16 @@ router.get('/channel/:userId/history', optionalAuth, async (req, res) => {if (!userId) return res.status(400).json({ error: 'Bad user id' });const room = `channel:${userId}`;const out = req.query.after_id != null-- ? historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit })-- : historyStore.page(room, { limit: req.query.limit, before: req.query.before });-- out.messages = withoutBlocked(req, res, out.messages);++ ? await historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit })++ : await historyStore.page(room, { limit: req.query.limit, before: req.query.before });++ out.messages = await withoutBlocked(req, res, out.messages);await loadDecor(out.messages);-- out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));++ out.messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(out.messages.map((x) => ({ ...x })))));let liveSlots = [], channel = null;try {-- const owner = ctx.getUserById(userId);++ const owner = await ctx.getUserById(userId);channel = owner ? owner.username : null;-- liveSlots = (ctx.getManagedStreamsByUserId(userId) || [])++ liveSlots = (await ctx.getManagedStreamsByUserId(userId) || []).filter(ms => ms.is_currently_live).map(ms => ({ managed_stream_id: ms.id, slug: ms.slug, title: ms.title, live_session_id: ms.live_session_id }));} catch { /* non-critical */ }@@@ -718,7 +718,7 @@ router.post('/admin/purge/preview', requireAuth, searchLimit, async (req, res) =}} else {// Non-admin without streamId: scope to their most recent stream-- const userStreams = ctx.getStreamsByUserId(req.user.id, 1);++ const userStreams = await ctx.getStreamsByUserId(req.user.id, 1);if (!userStreams?.length) {return res.json({ count: 0 });}@@@ -726,7 +726,7 @@ router.post('/admin/purge/preview', requireAuth, searchLimit, async (req, res) =}}-- const count = db.countChatMessagesByTimeRange(effectiveStreamId, from, to);++ const count = await db.countChatMessagesByTimeRange(effectiveStreamId, from, to);res.json({ count });} catch (e) {console.error('[Chat] Purge preview error:', e.message);@@@ -749,7 +749,7 @@ router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, ho}} else {// Non-admin without streamId: scope to their most recent stream-- const userStreams = ctx.getStreamsByUserId(req.user.id, 1);++ const userStreams = await ctx.getStreamsByUserId(req.user.id, 1);if (!userStreams?.length) {return res.json({ deleted: 0 });}@@@ -757,7 +757,7 @@ router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, ho}}-- const result = db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);++ const result = await db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);// Every surface that showed those lines: the stream's sockets get the range (Live's chat.js// drops it by time, with its "purged" notice) and, by id, the rest of the channel room (other@@@ -769,15 +769,15 @@ router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, hoconst ids = (result && result.ids) || [];const gone = { type: 'delete-messages', ids };if (effectiveStreamId) {-- chatServer.broadcastToStream(effectiveStreamId, payload);++ await chatServer.broadcastToStream(effectiveStreamId, payload);if (ids.length) {-- const ownerId = ctx.getStreamById(effectiveStreamId)?.user_id || null;-- chatServer.broadcastToChannelRoom(ownerId, effectiveStreamId, gone);-- chatServer.forwardToGlobal(effectiveStreamId, gone);++ const ownerId = (await ctx.getStreamById(effectiveStreamId))?.user_id || null;++ await chatServer.broadcastToChannelRoom(ownerId, effectiveStreamId, gone);++ await chatServer.forwardToGlobal(effectiveStreamId, gone);}} else {-- chatServer.broadcastGlobal(payload);-- if (ids.length) chatServer.broadcastGlobal(gone);++ await chatServer.broadcastGlobal(payload);++ if (ids.length) await chatServer.broadcastGlobal(gone);}} catch { /* chat server may not be initialized */ }@@@ -804,7 +804,7 @@ router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {}} else {// Non-admin without streamId: scope to their most recent stream-- const userStreams = ctx.getStreamsByUserId(req.user.id, 1);++ const userStreams = await ctx.getStreamsByUserId(req.user.id, 1);if (!userStreams?.length) {return res.json({ rows: [], total: 0, page: 1, limit: 50, totalPages: 0 });}@@@ -812,7 +812,7 @@ router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {}}-- const result = db.getChatLogs({++ const result = await db.getChatLogs({streamId: effectiveStreamId,username, search, from, to, messageType,page: parseInt(page) || 1,@@@ -821,8 +821,8 @@ router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {});// Staff browsing beyond their own stream: the first page of each view is recorded.if (permissions.can(req.user, 'staff.moderation.purge') && (parseInt(page) || 1) === 1) {-- const own = effectiveStreamId && (ctx.getStreamsByUserId(req.user.id, 50) || []).some((st) => st.id === effectiveStreamId);-- if (!own) auditLogAccess(req, 'chat_log_view', { scope_type: effectiveStreamId ? 'stream' : 'site', scope_id: effectiveStreamId || null,++ const own = effectiveStreamId && (await ctx.getStreamsByUserId(req.user.id, 50) || []).some((st) => st.id === effectiveStreamId);++ if (!own) await auditLogAccess(req, 'chat_log_view', { scope_type: effectiveStreamId ? 'stream' : 'site', scope_id: effectiveStreamId || null,details: { username: username || null, search: search ? String(search).slice(0, 100) : null, from: from || null, to: to || null, include_deleted: includeDeleted === 'true', total: result.total } });}publicRows(result.rows);@@@ -850,14 +850,14 @@ router.get('/admin/logs/export', requireAuth, exportLimit, async (req, res) => {}// Get all matching rows (up to 50k)-- const result = db.getChatLogs({++ const result = await db.getChatLogs({streamId: streamId ? parseInt(streamId) : undefined,username, search, from, to, messageType,page: 1, limit: 50000,});if (permissions.can(req.user, 'staff.moderation.purge')) {-- const own = streamId && (ctx.getStreamsByUserId(req.user.id, 50) || []).some((st) => st.id === parseInt(streamId));-- if (!own) auditLogAccess(req, 'chat_log_export', { scope_type: streamId ? 'stream' : 'site', scope_id: streamId ? parseInt(streamId) : null,++ const own = streamId && (await ctx.getStreamsByUserId(req.user.id, 50) || []).some((st) => st.id === parseInt(streamId));++ if (!own) await auditLogAccess(req, 'chat_log_export', { scope_type: streamId ? 'stream' : 'site', scope_id: streamId ? parseInt(streamId) : null,details: { format: format === 'csv' ? 'csv' : 'json', rows: result.rows.length, username: username || null, search: search ? String(search).slice(0, 100) : null, from: from || null, to: to || null } });}publicRows(result.rows);@@@ -882,13 +882,13 @@ router.get('/admin/logs/export', requireAuth, exportLimit, async (req, res) => {// ── Your own chat, to keep (WS-I task 7) ─────────────────────────────────────// Every message you sent that is still visible (not deleted, not expired), newest first, as JSON or// CSV. Up to 100,000 lines; `truncated` says when there were more.--router.get('/me/export', requireAuth, exportLimit, (req, res) => {++router.get('/me/export', requireAuth, exportLimit, async (req, res) => {try {const MAX = 100000, PAGE = 5000;const rows = [];let total = 0;for (let offset = 0; offset < MAX; offset += PAGE) {-- const r = db.getUserChatHistory(req.user.id, PAGE, offset);++ const r = await db.getUserChatHistory(req.user.id, PAGE, offset);total = r.total;rows.push(...r.messages);if (r.messages.length < PAGE) break;@@@ -148,11 +148,11 @@ function serializeSound(s) {}// ── List a channel's sounds ──────────────────────────────────--router.get('/channel/:userId', (req, res) => {++router.get('/channel/:userId', async (req, res) => {try {const userId = parseInt(req.params.userId);if (!userId) return res.json({ sounds: [] });-- res.json({ sounds: db.getChannelSounds(userId).map(serializeSound) });++ res.json({ sounds: (await db.getChannelSounds(userId)).map(serializeSound) });} catch (err) {res.status(500).json({ error: 'Failed to load channel sounds' });}@@@ -163,7 +163,7 @@ router.get('/all/:streamId', async (req, res) => {try {const stream = await ctx.ensureStream(parseInt(req.params.streamId));if (!stream?.user_id) return res.json({ sounds: [] });-- res.json({ sounds: db.getChannelSounds(stream.user_id).map(serializeSound) });++ res.json({ sounds: (await db.getChannelSounds(stream.user_id)).map(serializeSound) });} catch (err) {res.status(500).json({ error: 'Failed to load sounds' });}@@@ -198,8 +198,8 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r}await ctx.ensurePolicy(channel.id);-- const settings = ctx.getChannelModerationSettings(channel.id);-- const isMod = permissions.canModerateChannel(req.user, channel.id);++ const settings = await ctx.getChannelModerationSettings(channel.id);++ const isMod = await permissions.canModerateChannel(req.user, channel.id);const isOwnChannel = channelOwnerId === req.user.id;if (!isMod && !settings.custom_sounds_enabled) {@@@ -213,18 +213,18 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r// Adding a NEW file to an EXISTING command is restricted to that command's// creator (or channel mods/owner), so viewers can't hijack someone's command.-- const existingForCmd = db.getChannelSoundByCommand(channelOwnerId, command);++ const existingForCmd = await db.getChannelSoundByCommand(channelOwnerId, command);if (existingForCmd && !isMod && !isOwnChannel && existingForCmd.created_by !== req.user.id) {cleanup();return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can add more sounds to it.` });}// Per-channel + per-uploader caps-- if (db.countChannelSounds(channelOwnerId) >= config.sounds.maxPerChannel) {++ if (await db.countChannelSounds(channelOwnerId) >= config.sounds.maxPerChannel) {cleanup();return res.status(400).json({ error: `This channel has reached its sound limit (${config.sounds.maxPerChannel}).` });}-- if (!isMod && db.countChannelSoundsByUploader(channelOwnerId, req.user.id) >= config.sounds.maxPerUploaderPerChannel) {++ if (!isMod && await db.countChannelSoundsByUploader(channelOwnerId, req.user.id) >= config.sounds.maxPerUploaderPerChannel) {cleanup();return res.status(400).json({ error: `You've reached your upload limit for this channel (${config.sounds.maxPerUploaderPerChannel}).` });}@@@ -262,7 +262,7 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r// Emote attached to the command (per-command; new files inherit the existing one).const emoteCode = String(req.body.emote_code || (existingForCmd && existingForCmd.emote_code) || '').trim().slice(0, 32);-- const result = db.createChannelSound({++ const result = await db.createChannelSound({channel_owner_id: channelOwnerId,command,url: finalPath,@@@ -273,12 +273,12 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (remote_code: emoteCode,});// If an emote was (re)specified, apply it to every sound under this command.-- if (req.body.emote_code !== undefined) { try { db.setChannelSoundEmote(channelOwnerId, command, emoteCode); } catch { /* */ } }++ if (req.body.emote_code !== undefined) { try { await db.setChannelSoundEmote(channelOwnerId, command, emoteCode); } catch { /* */ } }// Live's media-proxy/asset-sync mirrors the file to OpenVibe.Media (from Live's copy of the row).try { ctx.effects.assetSync('syncSoon'); } catch { /* mirror is best-effort */ }// Tell everyone watching this channel to refresh their sound list live.-- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }++ try { await require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }res.json({sound: {@@@ -314,20 +314,20 @@ router.use((err, req, res, next) => {// ── Delete a sound ───────────────────────────────────────────router.delete('/:id', requireAuth, editLimit, async (req, res) => {try {-- const sound = db.getChannelSoundById(parseInt(req.params.id));++ const sound = await db.getChannelSoundById(parseInt(req.params.id));if (!sound) return res.status(404).json({ error: 'Sound not found' });let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');if (!allowed) {const channel = await channelWithPolicy(sound.channel_owner_id);-- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;++ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;}if (!allowed) return res.status(403).json({ error: 'Not your sound' });if (sound.url && fs.existsSync(sound.url)) { try { fs.unlinkSync(sound.url); } catch {} }// The Media asset id lives on Live's copy of the row: Live removes the asset first.try { await ctx.effects.assetSync('remove-sound', sound.id); } catch { /* */ }-- db.deleteChannelSound(sound.id);-- try { require('./chat-server').broadcastToOwnerStreams(sound.channel_owner_id, { type: 'sounds-updated' }); } catch { /* */ }++ await db.deleteChannelSound(sound.id);++ try { await require('./chat-server').broadcastToOwnerStreams(sound.channel_owner_id, { type: 'sounds-updated' }); } catch { /* */ }res.json({ message: 'Sound deleted' });} catch (err) {res.status(500).json({ error: 'Failed to delete sound' });@@@ -350,10 +350,10 @@ router.patch('/command', requireAuth, editLimit, async (req, res) => {if (!channel) return res.status(404).json({ error: 'Channel not found' });const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');-- const group = (db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);++ const group = (await db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);if (!group.length) return res.status(404).json({ error: `No sound command !${command} in this channel.` });-- const isMod = permissions.canModerateChannel(req.user, channel.id);++ const isMod = await permissions.canModerateChannel(req.user, channel.id);const isOwnChannel = channelOwnerId === req.user.id;const isCreator = group.every((s) => s.created_by === req.user.id);if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {@@@ -369,11 +369,11 @@ router.patch('/command', requireAuth, editLimit, async (req, res) => {if (RESERVED_COMMANDS.has(next)) {return res.status(400).json({ error: `"!${next}" is a reserved command — pick another name` });}-- if (next !== command && db.getChannelSoundByCommand(channelOwnerId, next)) {++ if (next !== command && await db.getChannelSoundByCommand(channelOwnerId, next)) {return res.status(409).json({ error: `!${next} already exists in this channel.` });}if (next !== command) {-- db.renameChannelSoundCommand(channelOwnerId, command, next);++ await db.renameChannelSoundCommand(channelOwnerId, command, next);finalCommand = next;}}@@@ -382,10 +382,10 @@ router.patch('/command', requireAuth, editLimit, async (req, res) => {if (emoteCode && !/^[a-zA-Z0-9_]{2,32}$/.test(emoteCode)) {return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });}-- db.setChannelSoundEmote(channelOwnerId, finalCommand, emoteCode);++ await db.setChannelSoundEmote(channelOwnerId, finalCommand, emoteCode);}-- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }++ try { await require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }res.json({ message: 'Sound command updated', command: finalCommand });} catch (err) {res.status(500).json({ error: 'Failed to update sound command' });@@@ -421,7 +421,7 @@ function _alertKind(raw) { return raw === 'goal' ? 'goal' : 'donation'; }// GET which alert sounds are configured (+ a preview URL).router.get('/alert/mine', requireAuth, async (req, res) => {await channelWithPolicy(req.user.id);-- const s = ctx.getChannelAlertSoundsByUser(req.user.id) || {};++ const s = await ctx.getChannelAlertSoundsByUser(req.user.id) || {};const toUrl = (p) => (p ? `/api/sounds/file/${path.basename(p)}` : null);res.json({donation: { set: !!s.donation_sound_url, url: toUrl(s.donation_sound_url) },@@@ -451,7 +451,7 @@ router.post('/alert/:kind', requireAuth, uploadLimit, soundUpload.single('sound'// Remove the previous alert sound of this kind (best-effort).try {-- const prev = ctx.getChannelAlertSoundsByUser(req.user.id) || {};++ const prev = await ctx.getChannelAlertSoundsByUser(req.user.id) || {};const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;if (prevPath && fs.existsSync(prevPath) && path.resolve(prevPath) !== path.resolve(finalPath)) fs.unlink(prevPath, () => {});} catch { /* ignore */ }@@@ -474,7 +474,7 @@ router.delete('/alert/:kind', requireAuth, editLimit, async (req, res) => {try {const channel = await channelWithPolicy(req.user.id);if (channel) {-- const prev = ctx.getChannelAlertSoundsByUser(req.user.id) || {};++ const prev = await ctx.getChannelAlertSoundsByUser(req.user.id) || {};const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;if (prevPath && fs.existsSync(prevPath)) { try { fs.unlinkSync(prevPath); } catch { /* */ } }await ctx.effects.setChannelAlertSound(channel.id, kind, null, null, req.user.id);@@@ -15,8 +15,63 @@ const db = require('../db/database');const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;--const cols = (d, t) => { try { return d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name); } catch { return []; } };--const has = (d, t, c) => cols(d, t).includes(c);++// Static statements per table (plan T3 decision 7: no interpolated identifiers). @to/@from: a subject or a Live id.++const BY_SUBJECT_MESSAGES = [++ 'UPDATE chat_messages SET subject_id = @to WHERE subject_id = @from',++ 'UPDATE room_messages SET subject_id = @to WHERE subject_id = @from',++ 'UPDATE dm_messages SET sender_subject_id = @to WHERE sender_subject_id = @from',++];++const BY_USER = [++ 'UPDATE chat_messages SET user_id = @to WHERE user_id = @from',++ 'UPDATE chat_messages SET channel_user_id = @to WHERE channel_user_id = @from',++ 'UPDATE room_messages SET user_id = @to WHERE user_id = @from',++ 'UPDATE dm_messages SET sender_id = @to WHERE sender_id = @from',++ 'UPDATE dm_conversations SET created_by = @to WHERE created_by = @from',++];++// Membership: (group, user) is one row; the survivor's stays.++const MEMBERSHIP = [++ {++ counter: 'dm',++ groups: 'SELECT conversation_id AS g FROM dm_participants WHERE user_id = ?',++ has: 'SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?',++ drop: 'DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?',++ move: 'UPDATE dm_participants SET user_id = ? WHERE conversation_id = ? AND user_id = ?',++ subject: 'UPDATE dm_participants SET subject_id = @to WHERE subject_id = @from',++ },++ {++ counter: 'rooms',++ groups: 'SELECT room_id AS g FROM room_members WHERE user_id = ?',++ has: 'SELECT 1 FROM room_members WHERE room_id = ? AND user_id = ?',++ drop: 'DELETE FROM room_members WHERE room_id = ? AND user_id = ?',++ move: 'UPDATE room_members SET user_id = ? WHERE room_id = ? AND user_id = ?',++ subject: null,++ },++];++const BY_SUBJECT_OTHER = [++ 'UPDATE rooms SET owner_subject = @to WHERE owner_subject = @from',++ 'UPDATE room_attachments SET attached_by_subject = @to WHERE attached_by_subject = @from',++ 'UPDATE calls SET created_by_subject = @to WHERE created_by_subject = @from',++ 'UPDATE calls SET target_subject = @to WHERE target_subject = @from',++];++// Blocks, from either side: the survivor's pair wins, blocking oneself goes.++const DM_BLOCKS = [++ { rows: 'SELECT id, blocked_id AS o FROM dm_blocks WHERE blocker_id = ?', exists: 'SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?', move: 'UPDATE dm_blocks SET blocker_id = ? WHERE id = ?' },++ { rows: 'SELECT id, blocker_id AS o FROM dm_blocks WHERE blocked_id = ?', exists: 'SELECT 1 FROM dm_blocks WHERE blocked_id = ? AND blocker_id = ?', move: 'UPDATE dm_blocks SET blocked_id = ? WHERE id = ?' },++];++const NETWORK_BLOCKS = [++ {++ rows: 'SELECT blocked_subject AS o FROM network_blocks WHERE blocker_subject = ?',++ exists: 'SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ?',++ drop: 'DELETE FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ?',++ move: 'UPDATE network_blocks SET blocker_subject = ? WHERE blocker_subject = ? AND blocked_subject = ?',++ },++ {++ rows: 'SELECT blocker_subject AS o FROM network_blocks WHERE blocked_subject = ?',++ exists: 'SELECT 1 FROM network_blocks WHERE blocked_subject = ? AND blocker_subject = ?',++ drop: 'DELETE FROM network_blocks WHERE blocked_subject = ? AND blocker_subject = ?',++ move: 'UPDATE network_blocks SET blocked_subject = ? WHERE blocked_subject = ? AND blocker_subject = ?',++ },++];/** The payload { merge_id, from, into }, or null. */function payloadOf(event) {@@@ -25,53 +80,44 @@ function payloadOf(event) {return { merge_id: p.merge_id, from: p.from, into: p.into };}--function apply({ from, into, merge_id: mergeId }, { log = console } = {}) {++async function apply({ from, into, merge_id: mergeId }, { log = console } = {}) {const d = db.getDb();-- const idOf = (s) => { const r = d.prepare('SELECT id FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1').get(s); return r ? r.id : null; };-- const a = idOf(from); const b = idOf(into);++ const idOf = async (s) => { const r = await d.prepare('SELECT id FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1').get(s); return r ? r.id : null; };++ const a = await idOf(from); const b = await idOf(into);const both = a != null && b != null && a !== b;const c = { messages: 0, dm: 0, rooms: 0, dropped: 0 };-- d.transaction(() => {-- const set = (t, col, to, fromVal) => (has(d, t, col) ? d.prepare(`UPDATE ${t} SET ${col} = ? WHERE ${col} = ?`).run(to, fromVal).changes : 0);++ await d.tx(async () => {++ const set = async (text, to, fromVal) => (await d.prepare(text).run({ to, from: fromVal })).changes;// Messages: by subject, and by Live user id when both accounts have one.-- c.messages += set('chat_messages', 'subject_id', into, from) + set('room_messages', 'subject_id', into, from) + set('dm_messages', 'sender_subject_id', into, from);-- if (both) {-- set('chat_messages', 'user_id', b, a); set('chat_messages', 'channel_user_id', b, a);-- set('room_messages', 'user_id', b, a); set('dm_messages', 'sender_id', b, a);-- set('dm_conversations', 'created_by', b, a);-- }-- // Membership: (conversation, user) and (room, user) are one row each; the survivor's stays.-- for (const [t, group] of [['dm_participants', 'conversation_id'], ['room_members', 'room_id']]) {-- if (!has(d, t, 'user_id')) continue;++ for (const q of BY_SUBJECT_MESSAGES) c.messages += await set(q, into, from);++ if (both) for (const q of BY_USER) await set(q, b, a);++ for (const m of MEMBERSHIP) {if (both) {-- for (const r of d.prepare(`SELECT ${group} AS g FROM ${t} WHERE user_id = ?`).all(a)) {-- if (d.prepare(`SELECT 1 FROM ${t} WHERE ${group} = ? AND user_id = ?`).get(r.g, b)) { d.prepare(`DELETE FROM ${t} WHERE ${group} = ? AND user_id = ?`).run(r.g, a); c.dropped++; }-- else { d.prepare(`UPDATE ${t} SET user_id = ? WHERE ${group} = ? AND user_id = ?`).run(b, r.g, a); c[t === 'dm_participants' ? 'dm' : 'rooms']++; }++ for (const r of await d.prepare(m.groups).all(a)) {++ if (await d.prepare(m.has).get(r.g, b)) { await d.prepare(m.drop).run(r.g, a); c.dropped++; }++ else { await d.prepare(m.move).run(b, r.g, a); c[m.counter]++; }}}-- if (has(d, t, 'subject_id')) set(t, 'subject_id', into, from);++ if (m.subject) await set(m.subject, into, from);}-- set('rooms', 'owner_subject', into, from); set('room_attachments', 'attached_by_subject', into, from);-- set('calls', 'created_by_subject', into, from); set('calls', 'target_subject', into, from);++ for (const q of BY_SUBJECT_OTHER) await set(q, into, from);// DM blocks (by Live user id, unique per pair) and the platform-block projection (by subject).-- if (both && has(d, 'dm_blocks', 'blocker_id')) {-- for (const [col, other] of [['blocker_id', 'blocked_id'], ['blocked_id', 'blocker_id']]) {-- for (const r of d.prepare(`SELECT id, ${other} AS o FROM dm_blocks WHERE ${col} = ?`).all(a)) {-- if (r.o === b || d.prepare(`SELECT 1 FROM dm_blocks WHERE ${col} = ? AND ${other} = ?`).get(b, r.o)) { d.prepare('DELETE FROM dm_blocks WHERE id = ?').run(r.id); c.dropped++; }-- else d.prepare(`UPDATE dm_blocks SET ${col} = ? WHERE id = ?`).run(b, r.id);++ if (both) {++ for (const q of DM_BLOCKS) {++ for (const r of await d.prepare(q.rows).all(a)) {++ if (r.o === b || await d.prepare(q.exists).get(b, r.o)) { await d.prepare('DELETE FROM dm_blocks WHERE id = ?').run(r.id); c.dropped++; }++ else await d.prepare(q.move).run(b, r.id);}}}-- set('dm_blocks', 'blocker_subject_id', into, from);-- if (has(d, 'network_blocks', 'blocker_subject')) {-- for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {-- for (const r of d.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {-- if (r.o === into || d.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, r.o)) d.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, r.o);-- else d.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, r.o);-- }++ await set('UPDATE dm_blocks SET blocker_subject_id = @to WHERE blocker_subject_id = @from', into, from);++ for (const q of NETWORK_BLOCKS) {++ for (const r of await d.prepare(q.rows).all(from)) {++ if (r.o === into || await d.prepare(q.exists).get(into, r.o)) await d.prepare(q.drop).run(from, r.o);++ else await d.prepare(q.move).run(into, from, r.o);}}-- })();++ });log.log(`[Merge] ${mergeId}: ${JSON.stringify({ live_users: [a, b], ...c })}`);return 'merged';}@@@ -209,10 +209,10 @@ function autoUserVoiceParams(identityKey) {const gap = h[3] % 4; // 0..3 (subtle cadence variation)return { voice, pitch, speed, gap };}--function deriveUserVoiceParams(identityKey) {++async function deriveUserVoiceParams(identityKey) {const key = String(identityKey || 'anon').trim().toLowerCase() || 'anon';// An admin-set override wins over the auto-assigned voice.-- try { const ov = db.getTtsVoiceOverride(key); if (ov && ov.voice) return _clampVoiceParams(ov); } catch { /* fall through to auto */ }++ try { const ov = await db.getTtsVoiceOverride(key); if (ov && ov.voice) return _clampVoiceParams(ov); } catch { /* fall through to auto */ }return autoUserVoiceParams(key);}@@@ -227,10 +227,10 @@ async function synthesizeUserVoice(text, identityKey, username, maxLengthOverridconst effMax = Math.min(1200, Math.max(1, Number(maxLengthOverride) || settings.maxLength));const cleanText = sanitize(text, effMax, username);if (!cleanText) return null;-- const params = deriveUserVoiceParams(identityKey);++ const params = await deriveUserVoiceParams(identityKey);const voiceDef = { engine: 'espeak-ng', params, name: `Voice-${params.voice}` };// Use a synthetic voiceId so the client can distinguish auto voices if needed.-- return _synthesizeWithDef(cleanText, voiceDef, `auto:${params.voice}`);++ return await _synthesizeWithDef(cleanText, voiceDef, `auto:${params.voice}`);}/** Invalidate settings cache (call after admin updates) */@@@ -542,10 +542,10 @@ async function synthesize(text, voiceId, username, maxLengthOverride) {if (!voiceDef) {// Fallback to defaultconst fallback = VOICE_CATALOG[settings.defaultVoice] || VOICE_CATALOG.gary;-- return _synthesizeWithDef(cleanText, fallback, settings.defaultVoice);++ return await _synthesizeWithDef(cleanText, fallback, settings.defaultVoice);}-- return _synthesizeWithDef(cleanText, voiceDef, vid);++ return await _synthesizeWithDef(cleanText, voiceDef, vid);}async function _synthesizeWithDef(text, voiceDef, voiceId) {@@@ -646,7 +646,7 @@ function getAvailableVoices() {async function synthesizeWithParams(text, params) {const p = _clampVoiceParams(params || {});const voiceDef = { engine: 'espeak-ng', params: p, name: `Voice-${p.voice}` };-- return _synthesizeWithDef(sanitize(text, getTTSSettings().maxLength, null), voiceDef, `preview:${p.voice}`);++ return await _synthesizeWithDef(sanitize(text, getTTSSettings().maxLength, null), voiceDef, `preview:${p.voice}`);}module.exports = {@@@ -192,22 +192,22 @@ async function queueRoom(req, res) {if (streamId) {const stream = await ctx.ensureStream(streamId).catch(() => null);if (!stream) { res.status(404).json({ error: 'No such stream' }); return null; }-- channel = stream.channel_id ? ctx.getChannelById(stream.channel_id) : ctx.getChannelByUserId(stream.user_id);++ channel = stream.channel_id ? await ctx.getChannelById(stream.channel_id) : await ctx.getChannelByUserId(stream.user_id);if (channel) await ctx.ensurePolicy(channel.id).catch(() => {}); // channel moderators-- allowed = permissions.canModerateStream(req.user, streamId);++ allowed = await permissions.canModerateStream(req.user, streamId);} else {-- channel = ctx.getChannelByUserId(channelUserId);++ channel = await ctx.getChannelByUserId(channelUserId);if (channel) await ctx.ensurePolicy(channel.id).catch(() => {});allowed = req.user.id === channelUserId || permissions.isGlobalModOrAbove(req.user)-- || (!!channel && permissions.isChannelMod(req.user, channel.id));++ || (!!channel && await permissions.isChannelMod(req.user, channel.id));}if (!allowed) { res.status(403).json({ error: 'Only the broadcaster and moderators can manage the TTS and sound queue' }); return null; }return { room: audioQueue.roomKey({ streamId, channelUserId }), streamId, channelUserId, channel };}--function logQueueAction(req, r, action, details) {++async function logQueueAction(req, r, action, details) {try {-- db.logModerationAction({++ await db.logModerationAction({scope_type: r.channel ? 'channel' : 'site',scope_id: r.channel ? r.channel.id : undefined,actor_user_id: req.user.id,@@@ -221,7 +221,7 @@ router.get('/queue', requireAuth, async (req, res) => {try {const r = await queueRoom(req, res);if (!r) return;-- res.json(audioQueue.list(r.room, { recent: req.query.recent }));++ res.json(await audioQueue.list(r.room, { recent: req.query.recent }));} catch (e) { res.status(500).json({ error: e.message }); }});@@@ -234,10 +234,10 @@ router.post('/queue/skip', requireAuth, queueModerate, async (req, res) => {const r = await queueRoom(req, res);if (!r) return;const id = parseInt(req.body && req.body.id, 10) || null;-- const skipped = audioQueue.skip(r.room, { id, actor: `user:${req.user.username}` });++ const skipped = await audioQueue.skip(r.room, { id, actor: `user:${req.user.username}` });if (!skipped) return res.status(id ? 404 : 409).json({ error: id ? 'No such request waiting or playing' : 'Nothing to skip' });-- logQueueAction(req, r, 'tts_skip', { request_id: skipped.id, kind: skipped.kind });-- res.json({ skipped: { id: skipped.id, kind: skipped.kind, requested_by: skipped.requested_by }, queue: audioQueue.list(r.room) });++ await logQueueAction(req, r, 'tts_skip', { request_id: skipped.id, kind: skipped.kind });++ res.json({ skipped: { id: skipped.id, kind: skipped.kind, requested_by: skipped.requested_by }, queue: await audioQueue.list(r.room) });} catch (e) { res.status(500).json({ error: e.message }); }});@@@ -245,9 +245,9 @@ router.post('/queue/clear', requireAuth, queueModerate, async (req, res) => {try {const r = await queueRoom(req, res);if (!r) return;-- const ids = audioQueue.clear(r.room, { actor: `user:${req.user.username}` });-- if (ids.length) logQueueAction(req, r, 'tts_clear', { request_ids: ids });-- res.json({ cleared: ids, queue: audioQueue.list(r.room) });++ const ids = await audioQueue.clear(r.room, { actor: `user:${req.user.username}` });++ if (ids.length) await logQueueAction(req, r, 'tts_clear', { request_ids: ids });++ res.json({ cleared: ids, queue: await audioQueue.list(r.room) });} catch (e) { res.status(500).json({ error: e.message }); }});@@@ -257,9 +257,9 @@ router.post('/queue/:id/report', requireAuth, queueReport, async (req, res) => {if (!r) return;const state = String((req.body && req.body.state) || '');if (state !== 'played' && state !== 'failed') return res.status(400).json({ error: "state must be 'played' or 'failed'" });-- const ok = audioQueue.report(r.room, parseInt(req.params.id, 10), state, { error: req.body && req.body.error, actor: `user:${req.user.username}` });++ const ok = await audioQueue.report(r.room, parseInt(req.params.id, 10), state, { error: req.body && req.body.error, actor: `user:${req.user.username}` });if (!ok) return res.status(409).json({ error: 'That request is not playing' });-- res.json({ ok: true, queue: audioQueue.list(r.room) });++ res.json({ ok: true, queue: await audioQueue.list(r.room) });} catch (e) { res.status(500).json({ error: e.message }); }});@@@ -38,15 +38,13 @@ module.exports = {hour: Math.max(1, int(process.env.CHAT_LIMITS_HOUR, 3000)),},-- // Chat's own SQLite database (systemd StateDirectory openvibe-chat). The move to PostgreSQL-- // (plan T3) adds db below; this path stays for the transition and for the import source.-- dbPath: process.env.CHAT_DB_PATH || './data/chat.db',--// PostgreSQL (ADR-035): DATABASE_URL serves (through PgBouncer), DATABASE_DIRECT_URL runs migrations-- // (the owner role, a direct connection). Development and `npm test` use an embedded PGlite instead.++ // (the owner role, a direct connection). Development and `npm test` use an embedded PGlite instead++ // (server/db/database.js openDb). DATABASE_POOL_MAX caps this process's pool (default 10).db: {url: process.env.DATABASE_URL || '',directUrl: process.env.DATABASE_DIRECT_URL || '',++ max: Math.max(1, int(process.env.DATABASE_POOL_MAX, 10)),},// Valkey: the shared per-actor rate-limit counters (openvibe-sdk/limits). Unset = in-process counters.@@@ -1,5 +1,5 @@/**-- * OpenVibe.Chat — database access (better-sqlite3).++ * OpenVibe.Chat — database access (PostgreSQL through openvibe-sdk/db; every helper is async).** The chat functions below are OpenVibe.Live's (server/db/database.js) moved as they were:* same names, same arguments, same SQL, so Live's forwarded calls (server/bridge/) and the moved@@@ -12,13 +12,16 @@*/'use strict';--const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');++const fs = require('fs');++const { createDb } = require('openvibe-sdk/db');const config = require('../config');++const MIGRATIONS = path.join(__dirname, '..', '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', '..', 'data', 'pglite');++let db = null;--let _dbPath = null;++let _idTables = null; // tables with an `id` column: an INSERT into one returns it (lastInsertRowid)const _stmts = new Map();// Tables Chat writes from the cutover on; Live keeps a read mirror of each (server/bridge/live-mirror.js).@@@ -30,116 +33,107 @@ const CHAT_TABLES = {dm_blocks: ['id'],tts_voice_overrides: ['identity_key'],channel_sounds: ['id'],-- relay_users: ['platform', 'username'],++ relay_users: ['id'],hidden_relay_users: ['id'],pending_ip_messages: ['id'],stream_first_chats: ['chatter_key', 'channel_user_id'],moderation_actions: ['id'],};--function getDb() {-- if (!db) {-- _dbPath = path.resolve(config.dbPath);-- fs.mkdirSync(path.dirname(_dbPath), { recursive: true });-- db = new Database(_dbPath);-- db.pragma('journal_mode = WAL');-- db.pragma('foreign_keys = ON');-- db.pragma('busy_timeout = 5000');-- try {-- db.pragma('synchronous = NORMAL');-- db.pragma('cache_size = -65536');-- db.pragma('temp_store = MEMORY');-- } catch (e) { console.warn('[DB] pragma tuning:', e.message); }++++/**++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer (the runtime role, DML only); migrations run++ * first as the owner on DATABASE_DIRECT_URL. Outside production without DATABASE_URL: an embedded PGlite++ * database in data/pglite (CHAT_PGLITE_DIR overrides it; one process). Timestamps stay SQLite-format text++ * (ov_now(), datetime() in migrations/0001_initial.sql).++ */++async function openDb(cfg = config, { log = console } = {}) {++ if (!cfg.db.url) {++ if (cfg.nodeEnv === 'production') throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh chat)');++ const dir = process.env.CHAT_PGLITE_DIR || DEV_PGLITE;++ if (cfg.nodeEnv !== 'test') log.warn(`[DB] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const d = createDb({ pglite: dir, service: 'chat', log });++ await d.migrate({ dir: MIGRATIONS, log: quiet(log) });++ return d;}++ if (!cfg.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: cfg.db.directUrl, service: 'chat-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log: quiet(log) }); } finally { await owner.close(); }++ return createDb({ url: cfg.db.url, service: 'chat', max: cfg.db.max, log });++}++function quiet(log) { return { log() {}, info() {}, warn: (...a) => log.warn(...a), error: (...a) => log.error(...a) }; }++++/** Open the process-wide database once, at boot (server/index.js, scripts). */++async function initDb(cfg = config, opts) {++ if (!db && globalThis.__ovChatTestDb) db = globalThis.__ovChatTestDb; // tests (test/helpers/pg-preload.mjs)++ if (!db) db = await openDb(cfg, opts);++ if (!_idTables) {++ const rows = await db.many(`SELECT table_name FROM information_schema.columns++ WHERE table_schema = current_schema() AND column_name = 'id'`);++ _idTables = new Set(rows.map((r) => r.table_name));++ }++ return db;++}++++/** The process-wide database (openvibe-sdk/db) initDb() opened. */++function getDb() {++ if (!db && globalThis.__ovChatTestDb) db = globalThis.__ovChatTestDb;++ if (!db) throw new Error('the database is not open: await initDb() at boot');return db;}++/** 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).++const INSERT_INTO = /^\s*INSERT\s+INTO\s+([a-z_]+)/i;++function withReturning(sql) {++ if (/\bRETURNING\b/i.test(sql)) return sql;++ const m = INSERT_INTO.exec(sql);++ return m && _idTables && _idTables.has(m[1].toLowerCase()) ? `${sql} RETURNING id` : sql;++}++function stmt(sql) {let s = _stmts.get(sql);if (!s) {-- s = getDb().prepare(sql);++ s = getDb().prepare(withReturning(sql));if (_stmts.size > 500) _stmts.clear();_stmts.set(sql, s);}return s;}--function run(sql, params = []) {++async function run(sql, params = []) {return stmt(sql).run(...(Array.isArray(params) ? params : [params]));}--function get(sql, params = []) {++async function get(sql, params = []) {return stmt(sql).get(...(Array.isArray(params) ? params : [params]));}--function all(sql, params = []) {++async function all(sql, params = []) {return stmt(sql).all(...(Array.isArray(params) ? params : [params]));}--function transaction(fn) {-- return getDb().transaction(fn)();++/** fn runs in one transaction; db calls inside it join it (openvibe-sdk/db ambient transactions). */++async function transaction(fn) {++ return await getDb().tx(async () => await fn());}--function close() {-- if (db) { try { db.close(); } catch { /* */ } }++async function close() {++ const d = db;db = null;_stmts.clear();--}----/**-- * Create the schema. opts.captureMirror installs this connection's TEMP triggers that record-- * every change to Chat's tables for the Live mirror — the service does; the importer does not,-- * so imported rows (which came from Live) are never sent back.-- */--const ADDED_COLUMNS = [-- ['emotes', 'media_url', 'TEXT'], // Live: emote images synced to OpenVibe.Media-- ['emotes', 'media_asset_id', 'INTEGER'],-- ['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it-- ['channel_moderation_settings', 'sub_only', 'INTEGER DEFAULT 0'], // sub-only chat (WS-I task 6), Live has it too-- ['ctx_channels', 'emote_sources', 'TEXT'], // the channel's emote source switches (T3, Live's channels row)--];----function initDb({ captureMirror = false } = {}) {-- const d = getDb();-- d.exec(fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'));-- // Columns Live added after a Chat database was created (CREATE TABLE IF NOT EXISTS does not add them).-- for (const [table, column, type] of ADDED_COLUMNS) {-- const have = d.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);-- if (!have) d.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);-- }-- // auth/network-session.js resolves a verified Network token to its user by subject.-- d.exec('CREATE INDEX IF NOT EXISTS idx_ctx_users_subject ON ctx_users(subject_id)');-- // C-04 is done: Chat is the only writer of the six tables, so there is no authority left to-- // record. Drop the table from databases an earlier release created (it stayed one release for N-1);-- // a new database never gets it (schema.sql no longer has it).-- d.exec('DROP TABLE IF EXISTS table_authority');-- if (captureMirror) installMirrorTriggers();-- return d;--}----function installMirrorTriggers() {-- const d = getDb();-- const tables = [-- ...Object.entries(CHAT_TABLES).map(([table, pk]) => [table, pk, '']),-- ];-- for (const [table, pk, when] of tables) {-- const obj = (alias) => `json_object(${pk.map((c) => `'${c}', ${alias}.${c}`).join(', ')})`;-- d.exec(`-- CREATE TEMP TRIGGER IF NOT EXISTS mirror_${table}_ins AFTER INSERT ON main.${table} ${when}-- BEGIN INSERT INTO live_mirror_outbox (tbl, op, pk) VALUES ('${table}', 'upsert', ${obj('NEW')}); END;-- CREATE TEMP TRIGGER IF NOT EXISTS mirror_${table}_upd AFTER UPDATE ON main.${table} ${when}-- BEGIN INSERT INTO live_mirror_outbox (tbl, op, pk) VALUES ('${table}', 'upsert', ${obj('NEW')}); END;-- CREATE TEMP TRIGGER IF NOT EXISTS mirror_${table}_del AFTER DELETE ON main.${table} ${when}-- BEGIN INSERT INTO live_mirror_outbox (tbl, op, pk) VALUES ('${table}', 'delete', ${obj('OLD')}); END;-- `);-- }++ if (d && d !== globalThis.__ovChatTestDb) { try { await d.close(); } catch { /* */ } }}// ── Subjects ──────────────────────────────────────────────────// Live user id → Network subject (usr_…) from the ctx_users projection. New rows carry it so a// later wave can drop Live ids.--function subjectFor(userId) {++async function subjectFor(userId) {if (!userId) return null;-- try { return get('SELECT subject_id FROM ctx_users WHERE id = ?', [userId])?.subject_id || null; } catch { return null; }++ try { return (await get('SELECT subject_id FROM ctx_users WHERE id = ?', [userId]))?.subject_id || null; } catch { return null; }}function _outbox() { return require('../events/outbox'); }@@@ -154,11 +148,11 @@ const DELETED_EVENT_IDS = 500;* author or who deleted it). payload.redacts asks OpenVibe.Events to turn the stored* chat.message.created of each id into a tombstone, so the text stops being replayable there.*/--function _announceDeleted(ids) {++async function _announceDeleted(ids) {const list = [...new Set((ids || []).map(Number).filter((n) => Number.isInteger(n) && n > 0))];for (let i = 0; i < list.length; i += DELETED_EVENT_IDS) {const part = list.slice(i, i + DELETED_EVENT_IDS);-- _outbox().enqueue({++ await _outbox().enqueue({event_type: 'chat.message.deleted',visibility: 'public',subject: { type: 'chat_message', id: String(part[0]) },@@@ -172,21 +166,21 @@ function _announceDeleted(ids) {// ── Chat messages ─────────────────────────────────────────────--function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata }) {++async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata }) {// channel_user_id = the broadcaster's user id — set for all channel/stream// messages so a streamer's chat history survives across sessions AND offline// periods (independent of the live-session stream row's lifetime).let chanUid = channel_user_id || null;-- if (!chanUid && stream_id) { try { chanUid = _ctx().getStreamById(stream_id)?.user_id || null; } catch { /* ignore */ } }++ if (!chanUid && stream_id) { try { chanUid = (await _ctx().getStreamById(stream_id))?.user_id || null; } catch { /* ignore */ } }const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));-- const subject = subjectFor(user_id);-- return transaction(() => {-- const res = run(++ const subject = await subjectFor(user_id);++ return await transaction(async () => {++ const res = await run(`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,[stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr, subject]);-- _outbox().enqueue({++ await _outbox().enqueue({event_type: 'chat.message.created',visibility: 'public',actorSubject: subject,@@@ -208,16 +202,16 @@ function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, usernam});}--function searchChatMessages({ query, userId, anonId, username, streamId, limit = 50, offset = 0 }) {++async function searchChatMessages({ query, userId, anonId, username, streamId, limit = 50, offset = 0 }) {let sql = `SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_colorFROM chat_messages cmLEFT JOIN ctx_users u ON cm.user_id = u.idWHERE cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;const params = [];if (query) {-- sql += ` AND cm.message LIKE ?`;++ sql += ` AND cm.message ILIKE ?`;params.push(`%${query}%`);}if (userId) {@@@ -229,7 +223,7 @@ function searchChatMessages({ query, userId, anonId, username, streamId, limit =params.push(anonId);}if (username) {-- sql += ` AND LOWER(u.username) LIKE ?`;++ sql += ` AND LOWER(u.username) ILIKE ?`;params.push(`%${username.toLowerCase()}%`);}if (streamId) {@@@ -238,73 +232,73 @@ function searchChatMessages({ query, userId, anonId, username, streamId, limit =}const countSql = sql.replace(/SELECT cm\.\*.*FROM/, 'SELECT COUNT(*) as c FROM');-- const total = get(countSql, params)?.c || 0;++ const total = (await get(countSql, params))?.c || 0;sql += ` ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;params.push(limit, offset);-- return { messages: all(sql, params), total };++ return { messages: await all(sql, params), total };}--function getUserChatHistory(userId, limit = 50, offset = 0) {++async function getUserChatHistory(userId, limit = 50, offset = 0) {const sql = `SELECT cm.*, s.title as stream_titleFROM chat_messages cmLEFT JOIN ctx_streams s ON cm.stream_id = s.idWHERE cm.user_id = ? AND cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;-- const messages = all(sql, [userId, limit, offset]);-- const total = get(++ const messages = await all(sql, [userId, limit, offset]);++ const total = (await get(`SELECT COUNT(*) as c FROM chat_messagesWHERE user_id = ? AND is_deleted = 0-- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,++ AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > ov_now())`,[userId]-- )?.c || 0;++ ))?.c || 0;return { messages, total };}--function getChatReplay(streamId, fromTime, toTime) {++async function getChatReplay(streamId, fromTime, toTime) {let sql = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_nameFROM chat_messages cmLEFT JOIN ctx_users u ON cm.user_id = u.idWHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat'-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;const params = [streamId];if (fromTime) { sql += ` AND cm.timestamp >= ?`; params.push(fromTime); }if (toTime) { sql += ` AND cm.timestamp <= ?`; params.push(toTime); }sql += ` ORDER BY cm.timestamp ASC`;-- return all(sql, params);++ return await all(sql, params);}/*** Get a single chat message by ID.*/--function getChatMessageById(id) {-- return get('SELECT * FROM chat_messages WHERE id = ?', [id]);++async function getChatMessageById(id) {++ return chatMessageRow(await get('SELECT * FROM chat_messages WHERE id = ?', [id]));}/** Shallow-merge a JSON patch into chat_messages.metadata (e.g. an async translation). */--function mergeChatMessageMetadata(id, patch) {++async function mergeChatMessageMetadata(id, patch) {if (!id || !patch || typeof patch !== 'object') return;-- const row = get('SELECT metadata FROM chat_messages WHERE id = ?', [id]);++ const row = await get('SELECT metadata FROM chat_messages WHERE id = ?', [id]);if (!row) return;let meta = {};try { meta = row.metadata ? JSON.parse(row.metadata) : {}; } catch { meta = {}; }if (!meta || typeof meta !== 'object' || Array.isArray(meta)) meta = {};-- return run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...meta, ...patch }), id]);++ return await run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...meta, ...patch }), id]);}/*** Soft-delete a chat message by ID. Sets is_deleted=1 and records who deleted it. Every delete below* also announces chat.message.deleted in the same transaction (_announceDeleted).*/--function deleteChatMessage(id, deletedBy = null) {-- return transaction(() => {-- const res = run(-- 'UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id = ?',++async function deleteChatMessage(id, deletedBy = null) {++ return await transaction(async () => {++ const res = await run(++ 'UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = ov_now() WHERE id = ?',[deletedBy, id]);-- if (res.changes) _announceDeleted([id]);++ if (res.changes) await _announceDeleted([id]);return res;});}@@@ -313,15 +307,15 @@ function deleteChatMessage(id, deletedBy = null) {* Soft-delete ALL chat messages from a specific user, optionally scoped to a stream.* Returns the list of deleted message IDs for real-time broadcast.*/--function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } = {}) {++async function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } = {}) {const condition = streamId? 'user_id = ? AND stream_id = ? AND is_deleted = 0': 'user_id = ? AND is_deleted = 0';const params = streamId ? [userId, streamId] : [userId];-- return transaction(() => {-- const ids = all(`SELECT id FROM chat_messages WHERE ${condition}`, params).map(m => m.id);++ return await transaction(async () => {++ const ids = (await all(`SELECT id FROM chat_messages WHERE ${condition} ORDER BY id`, params)).map(m => m.id);if (ids.length === 0) return [];-- _softDeleteIds(ids, deletedBy);++ await _softDeleteIds(ids, deletedBy);return ids;});}@@@ -329,15 +323,15 @@ function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } =/*** Soft-delete ALL chat messages from a specific anon_id, optionally scoped to stream.*/--function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } = {}) {++async function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } = {}) {const condition = streamId? 'anon_id = ? AND stream_id = ? AND is_deleted = 0': 'anon_id = ? AND is_deleted = 0';const params = streamId ? [anonId, streamId] : [anonId];-- return transaction(() => {-- const ids = all(`SELECT id FROM chat_messages WHERE ${condition}`, params).map(m => m.id);++ return await transaction(async () => {++ const ids = (await all(`SELECT id FROM chat_messages WHERE ${condition} ORDER BY id`, params)).map(m => m.id);if (ids.length === 0) return [];-- _softDeleteIds(ids, deletedBy);++ await _softDeleteIds(ids, deletedBy);return ids;});}@@@ -345,33 +339,33 @@ function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } =/*** Soft-delete ALL messages from a relayed external username (e.g. "[Twitch] foobar")*/--function deleteRelayUserMessages(username, { streamId = null, deletedBy = null } = {}) {++async function deleteRelayUserMessages(username, { streamId = null, deletedBy = null } = {}) {const condition = streamId? 'username = ? AND stream_id = ? AND is_deleted = 0': 'username = ? AND is_deleted = 0';const params = streamId ? [username, streamId] : [username];-- return transaction(() => {-- const ids = all(`SELECT id FROM chat_messages WHERE ${condition}`, params).map(m => m.id);++ return await transaction(async () => {++ const ids = (await all(`SELECT id FROM chat_messages WHERE ${condition} ORDER BY id`, params)).map(m => m.id);if (ids.length === 0) return [];-- _softDeleteIds(ids, deletedBy);++ await _softDeleteIds(ids, deletedBy);return ids;});}/** Mark `ids` deleted (in chunks, under SQLite's variable limit) and announce them. In a transaction. */--function _softDeleteIds(ids, deletedBy) {++async function _softDeleteIds(ids, deletedBy) {for (let i = 0; i < ids.length; i += DELETED_EVENT_IDS) {const part = ids.slice(i, i + DELETED_EVENT_IDS);-- run(-- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${part.map(() => '?').join(',')})`,++ await run(++ `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = ov_now() WHERE id IN (${part.map(() => '?').join(',')})`,[deletedBy, ...part]);}-- _announceDeleted(ids);++ await _announceDeleted(ids);}--function deleteExpiredChatMessages(limit = 500) {-- const rows = all(++async function deleteExpiredChatMessages(limit = 500) {++ const rows = await all(`SELECT id, stream_idFROM chat_messagesWHERE is_deleted = 0@@@ -379,8 +373,8 @@ function deleteExpiredChatMessages(limit = 500) {-- Compared raw, not through datetime(): wrapping the column in a function makes the-- index on auto_delete_at unusable, and this sweep runs every 30 seconds against the-- biggest table on the site. Values are stored in the same 'YYYY-MM-DD HH:MM:SS' shape-- -- CURRENT_TIMESTAMP produces, so a string comparison sorts identically.-- AND auto_delete_at <= CURRENT_TIMESTAMP++ -- ov_now() produces, so a string comparison sorts identically.++ AND auto_delete_at <= ov_now()ORDER BY auto_delete_at ASCLIMIT ?`,[Math.max(1, Number(limit) || 500)]@@@ -389,14 +383,14 @@ function deleteExpiredChatMessages(limit = 500) {const ids = rows.map(row => row.id);const placeholders = ids.map(() => '?').join(',');-- transaction(() => {-- run(++ await transaction(async () => {++ await run(`UPDATE chat_messages-- SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP++ SET is_deleted = 1, deleted_at = ov_now()WHERE id IN (${placeholders})`,ids);-- _announceDeleted(ids);++ await _announceDeleted(ids);});return rows;}@@@ -404,34 +398,34 @@ function deleteExpiredChatMessages(limit = 500) {// Time ranges (purge, its preview, the log filter): the dashboard sends ISO instants ('…T…Z') and// rows keep SQLite's 'YYYY-MM-DD HH:MM:SS'. Compared as TEXT, 'T' sorts after ' ', so a range// matched nothing on its first day and all of its last; datetime(?) reads both forms as UTC.--function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {++async function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {// Global chat is is_global = 1. The ids are read first, in the same transaction, to announce them.const where = streamId? 'stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0': 'is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0';const params = streamId ? [streamId, fromTime, toTime] : [fromTime, toTime];-- return transaction(() => {-- const ids = all(`SELECT id FROM chat_messages WHERE ${where}`, params).map(m => m.id);-- const res = run(-- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE ${where}`,++ return await transaction(async () => {++ const ids = (await all(`SELECT id FROM chat_messages WHERE ${where} ORDER BY id`, params)).map(m => m.id);++ const res = await run(++ `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = ov_now() WHERE ${where}`,[deletedBy, ...params]);-- _announceDeleted(ids);++ await _announceDeleted(ids);// The ids too, so the purge reaches every surface that showed them (not only the stream's sockets).return Object.assign(res, { ids });});}--function countChatMessagesByTimeRange(streamId, fromTime, toTime) {++async function countChatMessagesByTimeRange(streamId, fromTime, toTime) {let row;if (streamId) {-- row = get(++ row = await get(`SELECT COUNT(*) as cnt FROM chat_messagesWHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,[streamId, fromTime, toTime]);} else {-- row = get(++ row = await get(`SELECT COUNT(*) as cnt FROM chat_messagesWHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,[fromTime, toTime]@@@ -440,13 +434,13 @@ function countChatMessagesByTimeRange(streamId, fromTime, toTime) {return row?.cnt || 0;}--function getChatLogs({ streamId, username, search, from, to, messageType, page = 1, limit = 50, includeDeleted = false } = {}) {++async function getChatLogs({ streamId, username, search, from, to, messageType, page = 1, limit = 50, includeDeleted = false } = {}) {const conditions = [];const params = [];if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }-- if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }-- if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }++ if (username) { conditions.push('username ILIKE ?'); params.push(`%${username}%`); }++ if (search) { conditions.push('message ILIKE ?'); params.push(`%${search}%`); }if (from) { conditions.push('timestamp >= datetime(?)'); params.push(from); }if (to) { conditions.push('timestamp <= datetime(?)'); params.push(to); }if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }@@@ -455,19 +449,19 @@ function getChatLogs({ streamId, username, search, from, to, messageType, page =const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';const offset = (page - 1) * limit;-- const countRow = get(`SELECT COUNT(*) as total FROM chat_messages ${where}`, params);++ const countRow = await get(`SELECT COUNT(*) as total FROM chat_messages ${where}`, params);const total = countRow?.total || 0;-- const rows = all(++ const rows = (await all(`SELECT * FROM chat_messages ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,[...params, limit, offset]-- );++ )).map(chatMessageRow);return { rows, total, page, limit, totalPages: Math.ceil(total / limit) };}--function getRecentChatActivity(streamId, minutes) {++async function getRecentChatActivity(streamId, minutes) {const cutoff = new Date(Date.now() - minutes * 60 * 1000).toISOString();-- const row = get(++ const row = await get(`SELECT COUNT(*) as cnt FROM chat_messagesWHERE stream_id = ? AND timestamp >= ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'`,[streamId, cutoff]@@@ -479,32 +473,32 @@ function getRecentChatActivity(streamId, minutes) {* A user was renamed in Live (admin edit): chat_messages.username stores the display name at* creation time, so Live rewrote its rows in the same request. Chat does the same for its own.*/--function renameUserChatMessages(userId, newChatName) {-- return run('UPDATE chat_messages SET username = ? WHERE user_id = ?', [newChatName, userId]);++async function renameUserChatMessages(userId, newChatName) {++ return await run('UPDATE chat_messages SET username = ? WHERE user_id = ?', [newChatName, userId]);}// ── Relay (external platform) chatters ───────────────────────// Record a chat-relay (external platform) user's activity; keeps the earliest// first_seen as their "join date". Keyed case-insensitively by platform+username.--function recordRelayUser(platform, username) {++async function recordRelayUser(platform, username) {if (!platform || !username) return;const key = String(username).toLowerCase();try {-- run(`INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count)-- VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)++ await run(`INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count)++ VALUES (?, ?, ?, ov_now(), ov_now(), 1)ON CONFLICT(platform, username) DO UPDATE SET-- last_seen = CURRENT_TIMESTAMP,-- message_count = message_count + 1,++ last_seen = ov_now(),++ message_count = relay_users.message_count + 1,display_name = excluded.display_name`,[String(platform).toLowerCase(), key, String(username)]);} catch { /* non-critical */ }}--function getRelayUser(platform, username) {++async function getRelayUser(platform, username) {if (!platform || !username) return null;// rowid is a stable integer id for a relay user (no dedicated id column); used to key// their chat-AI insight in chat_ai_summaries.-- return get('SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?',++ return await get('SELECT * FROM relay_users WHERE platform = ? AND username = ?',[String(platform).toLowerCase(), String(username).toLowerCase()]) || null;}@@@ -512,18 +506,18 @@ function getRelayUser(platform, username) {// NULL user_id. Match a specific relay user by the trailing "] name" (LIKE is// case-insensitive for ASCII in SQLite), scoped to their platform.function _likeEscape(s) { return String(s).replace(/[\\%_]/g, '\\$&'); }--const _RELAY_MATCH = `cm.user_id IS NULL AND cm.source_platform = ? AND cm.username LIKE ? ESCAPE '\\'`;++const _RELAY_MATCH = `cm.user_id IS NULL AND cm.source_platform = ? AND cm.username ILIKE ? ESCAPE '\\'`;function _relayMatchParams(platform, rawUsername) {return [String(platform).toLowerCase(), '%] ' + _likeEscape(String(rawUsername))];}// A relay user's message history (for the "Chat Logs" viewer).--function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0, query = '' } = {}) {++async function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0, query = '' } = {}) {let where = `${_RELAY_MATCH} AND cm.is_deleted = 0`;const params = _relayMatchParams(platform, rawUsername);-- if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }-- const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;-- const rows = all(++ if (query) { where += ' AND cm.message ILIKE ?'; params.push('%' + query + '%'); }++ const total = (await get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params))?.c || 0;++ const rows = await all(`SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.stream_id,cm.source_platform, s.title AS stream_titleFROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id@@@ -536,9 +530,9 @@ function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0/*** Hide or ban a relayed external user.*/--function hideRelayUser({ channelId, platform, externalUsername, action = 'hide', reason, createdBy }) {-- return run(-- `INSERT OR REPLACE INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by)++async function hideRelayUser({ channelId, platform, externalUsername, action = 'hide', reason, createdBy }) {++ return await run(++ `INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by)VALUES (?, ?, ?, ?, ?, ?)`,[channelId || null, platform, externalUsername, action, reason || null, createdBy]);@@@ -548,8 +542,8 @@ function hideRelayUser({ channelId, platform, externalUsername, action = 'hide',* Check if a relayed user is hidden/banned.* Checks both channel-scoped and site-wide entries (channel_id IS NULL).*/--function isRelayUserHidden(channelId, platform, externalUsername) {-- return !!get(++async function isRelayUserHidden(channelId, platform, externalUsername) {++ return !!await get(`SELECT 1 FROM hidden_relay_usersWHERE platform = ? AND external_username = ? AND (channel_id = ? OR channel_id IS NULL)`,[platform, externalUsername, channelId]@@@ -559,8 +553,8 @@ function isRelayUserHidden(channelId, platform, externalUsername) {/*** Unhide/unban a relayed external user.*/--function unhideRelayUser(id) {-- return run('DELETE FROM hidden_relay_users WHERE id = ?', [id]);++async function unhideRelayUser(id) {++ return await run('DELETE FROM hidden_relay_users WHERE id = ?', [id]);}/**@@@ -568,9 +562,9 @@ function unhideRelayUser(id) {* channel). Scoped to a single relay identity — never touches a registered* openvibelive account of the same name. `channel_id IS ?` matches NULL safely.*/--function unhideRelayUserByIdentity(channelId, platform, externalUsername) {-- return run(-- 'DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS ?',++async function unhideRelayUserByIdentity(channelId, platform, externalUsername) {++ return await run(++ 'DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS NOT DISTINCT FROM ?',[platform, externalUsername, channelId || null]);}@@@ -586,17 +580,17 @@ function anonSubjectId(anonId) {// Anon meta for the context menu: first-seen (when their anon number was assigned — Live's// anon_ip_mappings, passed in by the caller) and first-chat (their earliest chat message), plus// total message count.--function getAnonMeta(anonId, firstSeenAt = null) {++async function getAnonMeta(anonId, firstSeenAt = null) {const num = anonSubjectId(anonId);-- const firstChat = get(++ const firstChat = (await get(`SELECT MIN(timestamp) AS t FROM chat_messagesWHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0`, [String(anonId)]-- )?.t || null;-- const count = get(++ ))?.t || null;++ const count = (await get(`SELECT COUNT(*) AS c FROM chat_messagesWHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0-- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`, [String(anonId)]-- )?.c || 0;++ AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > ov_now())`, [String(anonId)]++ ))?.c || 0;return {anon_id: anonId,anon_num: num || null,@@@ -607,13 +601,13 @@ function getAnonMeta(anonId, firstSeenAt = null) {}// An anon's message history (for the "Chat Logs" viewer).--function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {}) {++async function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {}) {let where = `cm.anon_id = ? AND cm.user_id IS NULL AND cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;const params = [String(anonId)];-- if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }-- const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;-- const rows = all(++ if (query) { where += ' AND cm.message ILIKE ?'; params.push('%' + query + '%'); }++ const total = (await get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params))?.c || 0;++ const rows = await all(`SELECT cm.id, cm.username, cm.anon_id, cm.message, cm.message_type, cm.timestamp, cm.stream_id,s.title AS stream_titleFROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id@@@ -631,8 +625,8 @@ function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {})* @param {number} channelUserId - the streamer's user ID* @returns {boolean} true if this is their first time*/--function isFirstChatInChannel(chatterKey, channelUserId) {-- const row = get(++async function isFirstChatInChannel(chatterKey, channelUserId) {++ const row = await get('SELECT 1 FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',[chatterKey, channelUserId]);@@@ -642,9 +636,9 @@ function isFirstChatInChannel(chatterKey, channelUserId) {/*** Record that a chatter has chatted in a streamer's channel.*/--function recordFirstChat(chatterKey, channelUserId) {-- run(-- 'INSERT OR IGNORE INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)',++async function recordFirstChat(chatterKey, channelUserId) {++ await run(++ 'INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?) ON CONFLICT DO NOTHING',[chatterKey, channelUserId]);}@@@ -655,14 +649,14 @@ function recordFirstChat(chatterKey, channelUserId) {* Log a moderation action for auditing.* Used by canvas, chat moderation, bans, etc.*/--function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {-- const actorSubject = subjectFor(actor_user_id);-- return transaction(() => {-- const res = run(`++async function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {++ const actorSubject = await subjectFor(actor_user_id);++ return await transaction(async () => {++ const res = await run(`INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details, actor_subject_id)VALUES (?, ?, ?, ?, ?, ?, ?)`, [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {}), actorSubject]);-- _outbox().enqueue({++ await _outbox().enqueue({event_type: 'chat.moderation.action',visibility: 'internal',actorSubject,@@@ -675,7 +669,7 @@ function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_actor_user_id: actor_user_id || null,actor_subject: actorSubject,target_user_id: target_user_id || null,-- target_subject: subjectFor(target_user_id),++ target_subject: await subjectFor(target_user_id),details: details || {},},});@@@ -688,8 +682,8 @@ function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_/*** Hold a message for IP approval.*/--function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, username, message }) {-- return run(++async function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, username, message }) {++ return await run(`INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, user_id, anon_id, username, message)VALUES (?, ?, ?, ?, ?, ?, ?)`,[channelId, streamId, ip, userId || null, anonId || null, username, message]@@@ -700,16 +694,16 @@ function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, usern* Approve or deny a pending IP message. If approved, auto-approve the IP too (approved_ips is* Live's table: the approval goes through live-context).*/--function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {++async function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {// Scoped to the channel the caller was authorised for, so a message id from another channel's// queue matches nothing.const scoped = channelId != null;const res = scoped-- ? run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?', [status, reviewedBy, id, channelId])-- : run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?', [status, reviewedBy, id]);++ ? await run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?', [status, reviewedBy, id, channelId])++ : await run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?', [status, reviewedBy, id]);if (!res.changes) return;if (status === 'approved') {-- const msg = get('SELECT * FROM pending_ip_messages WHERE id = ?', [id]);++ const msg = await get('SELECT * FROM pending_ip_messages WHERE id = ?', [id]);if (msg) _ctx().approveIp(channelId || msg.channel_id, msg.ip_address, reviewedBy, 'manual');}}@@@ -717,9 +711,9 @@ function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {/*** Bulk-approve all pending messages from a specific IP in a channel.*/--function approveAllFromIp(channelId, ip, reviewedBy) {++async function approveAllFromIp(channelId, ip, reviewedBy) {_ctx().approveIp(channelId, ip, reviewedBy, 'manual');-- return run(++ return await run("UPDATE pending_ip_messages SET status = 'approved', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",[reviewedBy, channelId, ip]);@@@ -728,94 +722,94 @@ function approveAllFromIp(channelId, ip, reviewedBy) {/*** Deny all pending messages from a specific IP in a channel.*/--function denyAllFromIp(channelId, ip, reviewedBy) {-- return run(++async function denyAllFromIp(channelId, ip, reviewedBy) {++ return await run("UPDATE pending_ip_messages SET status = 'denied', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",[reviewedBy, channelId, ip]);}// ── Per-user TTS voice overrides (admin-set) ─────────────────--function getTtsVoiceOverride(identityKey) {++async function getTtsVoiceOverride(identityKey) {try {const k = String(identityKey || '').trim().toLowerCase();if (!k) return null;-- const r = get('SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?', [k]);++ const r = await get('SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?', [k]);if (!r) return null;return { voice: r.voice, pitch: r.pitch, speed: r.speed, gap: r.gap || 0 };} catch { return null; }}--function setTtsVoiceOverride(identityKey, params, setBy) {++async function setTtsVoiceOverride(identityKey, params, setBy) {const k = String(identityKey || '').trim().toLowerCase();if (!k) return false;-- run(`INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at)-- VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)++ await run(`INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ov_now())ON CONFLICT(identity_key) DO UPDATE SET voice=excluded.voice, pitch=excluded.pitch,-- speed=excluded.speed, gap=excluded.gap, set_by=excluded.set_by, updated_at=CURRENT_TIMESTAMP`,++ speed=excluded.speed, gap=excluded.gap, set_by=excluded.set_by, updated_at=ov_now()`,[k, params.voice, params.pitch, params.speed, params.gap || 0, setBy || null]);return true;}--function deleteTtsVoiceOverride(identityKey) {-- try { run('DELETE FROM tts_voice_overrides WHERE identity_key = ?', [String(identityKey || '').trim().toLowerCase()]); return true; } catch { return false; }++async function deleteTtsVoiceOverride(identityKey) {++ try { await run('DELETE FROM tts_voice_overrides WHERE identity_key = ?', [String(identityKey || '').trim().toLowerCase()]); return true; } catch { return false; }}// ── Channel sound commands (viewer-uploadable) ───────────────--function createChannelSound({ channel_owner_id, command, url, mime = 'audio/mpeg', duration_seconds = 0, created_by = null, created_by_name = '', emote_code = '' }) {-- return run(++async function createChannelSound({ channel_owner_id, command, url, mime = 'audio/mpeg', duration_seconds = 0, created_by = null, created_by_name = '', emote_code = '' }) {++ return await run(`INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code, created_by_subject_id)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,-- [channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code || '', subjectFor(created_by)]++ [channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code || '', await subjectFor(created_by)]);}// Update the shared emote_code for all sounds under a command (an emote is per-command).--function setChannelSoundEmote(ownerId, command, emoteCode) {-- return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?',++async function setChannelSoundEmote(ownerId, command, emoteCode) {++ return await run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?',[emoteCode || '', ownerId, String(command || '').toLowerCase()]);}--function getChannelSounds(ownerId) {-- return all(++async function getChannelSounds(ownerId) {++ return await all('SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND is_approved = 1 ORDER BY command',[ownerId]);}--function getChannelSoundByCommand(ownerId, command) {++async function getChannelSoundByCommand(ownerId, command) {// A command may have multiple uploaded sounds — pick one at random each play.-- return get(++ return await get('SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND command = ? AND is_approved = 1 ORDER BY RANDOM() LIMIT 1',[ownerId, String(command || '').toLowerCase()]);}--function getChannelSoundById(id) {-- return get('SELECT * FROM channel_sounds WHERE id = ?', [id]);++async function getChannelSoundById(id) {++ return await get('SELECT * FROM channel_sounds WHERE id = ?', [id]);}--function countChannelSounds(ownerId) {-- const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?', [ownerId]);++async function countChannelSounds(ownerId) {++ const row = await get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?', [ownerId]);return row ? row.count : 0;}--function countChannelSoundsByUploader(ownerId, uploaderId) {-- const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?', [ownerId, uploaderId]);++async function countChannelSoundsByUploader(ownerId, uploaderId) {++ const row = await get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?', [ownerId, uploaderId]);return row ? row.count : 0;}--function deleteChannelSound(id) {-- return run('DELETE FROM channel_sounds WHERE id = ?', [id]);++async function deleteChannelSound(id) {++ return await run('DELETE FROM channel_sounds WHERE id = ?', [id]);}// Rename a whole !command group (a command may hold several sounds).--function renameChannelSoundCommand(ownerId, oldCommand, newCommand) {-- return run('UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?',++async function renameChannelSoundCommand(ownerId, oldCommand, newCommand) {++ return await run('UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?',[String(newCommand || '').toLowerCase(), ownerId, String(oldCommand || '').toLowerCase()]);}// Sounds attach emotes BY CODE — keep those references alive when an emote// is renamed so the streamer's emote+sound combos don't silently break.--function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {-- return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?',++async function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {++ return await run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?',[newCode || '', ownerId, oldCode]);}@@@ -823,18 +817,18 @@ function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {// Same SQL and shapes as Live's database.js helpers of the same name; usernames come from the// ctx_users projection instead of Live's users table.--function isChannelModerator(userId, channelId) {-- return !!get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [userId, channelId]);++async function isChannelModerator(userId, channelId) {++ return !!await get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [userId, channelId]);}/** The channel_moderation_settings row as it is, or null when the channel has none (defaults are Live's). */--function getChannelModerationSettingsRow(channelId) {-- return get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)]) || null;++async function getChannelModerationSettingsRow(channelId) {++ return await get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)]) || null;}/** A channel's moderators, with names, oldest first (Live's getChannelModerators). */--function getChannelModerators(channelId) {-- return all(`++async function getChannelModerators(channelId) {++ return await all(`SELECT cm.id, cm.user_id, cm.added_by, cm.created_at,u.username, u.display_name, u.avatar_url,a.username as added_by_username@@@ -847,16 +841,16 @@ function getChannelModerators(channelId) {}/** The channel's moderator user ids, oldest first (Live's policy answer carries the same list). */--function getChannelModeratorIds(channelId) {-- return all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY created_at ASC, id ASC', [channelId]).map((r) => r.user_id);++async function getChannelModeratorIds(channelId) {++ return (await all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY created_at ASC, id ASC', [channelId])).map((r) => r.user_id);}/*** The channels a user moderates (Live's getChannelsByModerator). Channel title and owner come from* the ctx_channels / ctx_users projections; the extra `id` alias is what the dashboard reads.*/--function getChannelsByModerator(userId) {-- return all(`++async function getChannelsByModerator(userId) {++ return await all(`SELECT cm.channel_id, cm.channel_id AS id, c.title,c.user_id AS user_id, c.user_id AS owner_user_id, u.username AS owner_usernameFROM channel_moderators cm@@@ -868,15 +862,15 @@ function getChannelsByModerator(userId) {}/** Custom emotes marked global (Live's getGlobalEmotes). */--function getGlobalEmotes() {-- return all(`SELECT e.*, u.username FROM emotes e++async function getGlobalEmotes() {++ return await all(`SELECT e.*, u.username FROM emotes eLEFT JOIN ctx_users u ON e.user_id = u.idWHERE e.is_global = 1 AND e.is_approved = 1 ORDER BY code`);}/** A channel's emotes: ones targeted at the owner, plus the owner's own legacy uploads. */--function getChannelEmotes(userId) {-- return all(++async function getChannelEmotes(userId) {++ return await all(`SELECT e.*, u.username, up.username AS uploader_username, up.display_name AS uploader_display_nameFROM emotes eLEFT JOIN ctx_users u ON e.user_id = u.id@@@ -889,16 +883,16 @@ function getChannelEmotes(userId) {}/** How many emotes a channel holds (Live's countChannelEmotes; ownerId is the streamer's user id). */--function countChannelEmotes(ownerId) {-- return get(++async function countChannelEmotes(ownerId) {++ return (await get('SELECT COUNT(*) as count FROM emotes WHERE (channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)',[ownerId, ownerId]-- )?.count || 0;++ ))?.count || 0;}/** The channel's emote with this code, any uploader (Live's getChannelEmoteByCode). */--function getChannelEmoteByCode(ownerId, code) {-- return get(++async function getChannelEmoteByCode(ownerId, code) {++ return await get(`SELECT * FROM emotesWHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))LIMIT 1`,@@@ -907,17 +901,26 @@ function getChannelEmoteByCode(ownerId, code) {}/** One emote by id, with its uploader's name (Live's getEmoteById). */--function getEmoteById(id) {-- return get('SELECT e.*, u.username FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id WHERE e.id = ?', [id]);++async function getEmoteById(id) {++ return await get('SELECT e.*, u.username FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id WHERE e.id = ?', [id]);}/** A user's own emotes (Live's getEmotesByUser). */--function getEmotesByUser(userId) {-- return all('SELECT * FROM emotes WHERE user_id = ? ORDER BY code', [userId]);++async function getEmotesByUser(userId) {++ return await all('SELECT * FROM emotes WHERE user_id = ? ORDER BY code', [userId]);}/** Moderation actions with actor/target names (Live's getModerationActions). */--function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_user_id, target_user_id, limit = 50, offset = 0 } = {}) {++// Two columns SQLite declared INTEGER hold ids or names (its type affinity took both): moderation_actions.scope_id++// (a channel/stream id or a room slug) and chat_messages.deleted_by (a user id, or the name a purge records). The++// PostgreSQL columns are text; an id reads back as a number wherever a row leaves Chat, as it did.++function _idBack(r, col) {++ return r && typeof r[col] === 'string' && /^-?\d{1,15}$/.test(r[col]) ? { ...r, [col]: Number(r[col]) } : r;++}++function moderationRow(r) { return _idBack(r, 'scope_id'); }++function chatMessageRow(r) { return _idBack(r, 'deleted_by'); }++++async function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_user_id, target_user_id, limit = 50, offset = 0 } = {}) {const conditions = [];const params = [];const st = scopeType || scope_type;@@@ -928,7 +931,7 @@ function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_if (target_user_id) { conditions.push('ma.target_user_id = ?'); params.push(target_user_id); }const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';params.push(limit, offset);-- return all(`++ return (await all(`SELECT ma.*, actor.username AS actor_username, target.username AS target_usernameFROM moderation_actions maLEFT JOIN ctx_users actor ON ma.actor_user_id = actor.id@@@ -936,25 +939,25 @@ function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_${where}ORDER BY ma.created_at DESCLIMIT ? OFFSET ?-- `, params);++ `, params)).map(moderationRow);}/*** A channel's chat messages matching a search (Live's searchChannelChatMessages): one of the* channel's streams, or its offline room (channel_user_id), never another channel's room.*/--function searchChannelChatMessages(channel, { query, userId, limit = 50, offset = 0 } = {}) {++async function searchChannelChatMessages(channel, { query, userId, limit = 50, offset = 0 } = {}) {const conditions = ['(cm.channel_user_id = ? OR cm.stream_id IN (SELECT id FROM ctx_streams WHERE channel_id = ? OR user_id = ?))','cm.is_deleted = 0',-- '(cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)',++ '(cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())',];const params = [channel.user_id, channel.id, channel.user_id];-- if (query) { conditions.push('cm.message LIKE ?'); params.push(`%${query}%`); }++ if (query) { conditions.push('cm.message ILIKE ?'); params.push(`%${query}%`); }if (userId) { conditions.push('cm.user_id = ?'); params.push(userId); }params.push(limit, offset);return {-- messages: all(`++ messages: await all(`SELECT cm.*, u.username, u.display_name, u.avatar_urlFROM chat_messages cmLEFT JOIN ctx_users u ON cm.user_id = u.id@@@ -985,29 +988,29 @@ function _writeResult(value, table, rows = [], deletedPks = []) {}// Channel moderators, settings and alert sounds (Live's /api/channels, the dashboard, /slow).--function addChannelModerator(channelId, userId, addedBy) {-- const out = transaction(() => {-- const res = run('INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)', [channelId, userId, addedBy]);-- return _writeResult(res, 'channel_moderators', all('SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]));++async function addChannelModerator(channelId, userId, addedBy) {++ const out = await transaction(async () => {++ const res = await run('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', [channelId, userId, addedBy]);++ return _writeResult(res, 'channel_moderators', await all('SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]));});-- _ctx().invalidateChannel(channelId);++ await _ctx().invalidateChannel(channelId);return out;}--function removeChannelModerator(channelId, userId) {-- const out = transaction(() => {-- const gone = all('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);-- const res = run('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);++async function removeChannelModerator(channelId, userId) {++ const out = await transaction(async () => {++ const gone = await all('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);++ const res = await run('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);return _writeResult(res, 'channel_moderators', [], gone.map((r) => ({ id: r.id })));});-- _ctx().invalidateChannel(channelId);++ await _ctx().invalidateChannel(channelId);return out;}// Live's upsertChannelModerationSettings, clamps and all.--function upsertChannelModerationSettings(channelId, fields) {-- const out = transaction(() => {-- const existing = get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);++async function upsertChannelModerationSettings(channelId, fields) {++ const out = await transaction(async () => {++ const existing = await get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);if (existing) {const updates = [];const params = [];@@@ -1049,13 +1052,13 @@ function upsertChannelModerationSettings(channelId, fields) {if (fields.sound_min_pitch_cents !== undefined) set('sound_min_pitch_cents', Math.min(0, Math.max(-2400, Math.round(Number(fields.sound_min_pitch_cents) || -1200))));if (fields.sound_max_pitch_cents !== undefined) set('sound_max_pitch_cents', Math.max(0, Math.min(2400, Math.round(Number(fields.sound_max_pitch_cents) || 1200))));if (updates.length > 0) {-- updates.push('updated_at = CURRENT_TIMESTAMP');++ updates.push('updated_at = ov_now()');params.push(channelId);-- run(`UPDATE channel_moderation_settings SET ${updates.join(', ')} WHERE channel_id = ?`, params);++ await run(`UPDATE channel_moderation_settings SET ${updates.join(', ')} WHERE channel_id = ?`, params);}} else {const b = (v, dflt) => (v !== undefined ? (v ? 1 : 0) : dflt);-- run(++ await run(`INSERT INTO channel_moderation_settings (channel_id, slow_mode_seconds, followers_only, emote_only,allow_anonymous, links_allowed, gifs_enabled, account_age_gate_hours,@@@ -1105,101 +1108,101 @@ function upsertChannelModerationSettings(channelId, fields) {}// tts_max_length and sub_only, as Live: after the UPDATE or the fresh INSERT.if (fields.tts_max_length !== undefined) {-- run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]);++ await run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]);}-- if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);-- const row = get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);++ if (fields.sub_only !== undefined) await run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);++ const row = await get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);return _writeResult(row, 'channel_moderation_settings', [row]);});-- _ctx().invalidateChannel(channelId);++ await _ctx().invalidateChannel(channelId);return out;}// Donation / goal alert sounds live on the settings row (url = the file's path in the sounds dir).--function setChannelAlertSound(channelId, kind, url, mime) {-- const out = transaction(() => {-- if (!get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId])) {-- run('INSERT INTO channel_moderation_settings (channel_id) VALUES (?)', [channelId]);++async function setChannelAlertSound(channelId, kind, url, mime) {++ const out = await transaction(async () => {++ if (!await get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId])) {++ await run('INSERT INTO channel_moderation_settings (channel_id) VALUES (?)', [channelId]);}const col = kind === 'goal' ? 'goal_sound' : 'donation_sound';-- const res = run(`UPDATE channel_moderation_settings SET ${col}_url = ?, ${col}_mime = ? WHERE channel_id = ?`, [url || null, mime || null, channelId]);-- return _writeResult(res, 'channel_moderation_settings', [get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId])]);++ const res = await run(`UPDATE channel_moderation_settings SET ${col}_url = ?, ${col}_mime = ? WHERE channel_id = ?`, [url || null, mime || null, channelId]);++ return _writeResult(res, 'channel_moderation_settings', [await get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId])]);});-- _ctx().invalidateChannel(channelId);++ await _ctx().invalidateChannel(channelId);return out;}// Emotes (Live's /api/emotes; media_url/media_asset_id are OpenVibe.Media's copy — Live's// asset-sync filled them onto the shared rows, Chat uploads through its own Media token now).--function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {-- return transaction(() => {-- const res = run(++async function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {++ return await transaction(async () => {++ const res = await run(`INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,[user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100)), media_url || null, media_asset_id || null]);-- return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);++ return _writeResult(res, 'emotes', [await get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);});}--function updateEmote(id, { code, size } = {}) {++async function updateEmote(id, { code, size } = {}) {const sets = [];const params = [];if (code !== undefined) { sets.push('code = ?'); params.push(code); }if (size !== undefined) { sets.push('size = ?'); params.push(Math.min(400, Math.max(25, parseInt(size, 10) || 100))); }if (!sets.length) return _writeResult({ changes: 0 }, 'emotes');-- return transaction(() => {-- const res = run(`UPDATE emotes SET ${sets.join(', ')} WHERE id = ?`, [...params, id]);-- return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);++ return await transaction(async () => {++ const res = await run(`UPDATE emotes SET ${sets.join(', ')} WHERE id = ?`, [...params, id]);++ return _writeResult(res, 'emotes', [await get('SELECT * FROM emotes WHERE id = ?', [id])]);});}--function deleteEmote(id) {-- return transaction(() => {-- const had = get('SELECT id FROM emotes WHERE id = ?', [id]);-- const res = run('DELETE FROM emotes WHERE id = ?', [id]);++async function deleteEmote(id) {++ return await transaction(async () => {++ const had = await get('SELECT id FROM emotes WHERE id = ?', [id]);++ const res = await run('DELETE FROM emotes WHERE id = ?', [id]);return _writeResult(res, 'emotes', [], had ? [{ id: had.id }] : []);});}/** The emote's copy on OpenVibe.Media (Live's asset-sync). */--function setEmoteMedia(id, mediaUrl, mediaAssetId) {-- return transaction(() => {-- const res = run('UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl || null, mediaAssetId || null, id]);-- return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);++async function setEmoteMedia(id, mediaUrl, mediaAssetId) {++ return await transaction(async () => {++ const res = await run('UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl || null, mediaAssetId || null, id]);++ return _writeResult(res, 'emotes', [await get('SELECT * FROM emotes WHERE id = ?', [id])]);});}// User tags (owned chat tags; Live has had no writer since its game tags went read-only).--function grantUserTag(userId, tagId, source = 'shop') {-- return transaction(() => {-- const res = run('INSERT OR IGNORE INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)', [userId, String(tagId), source || 'shop']);-- return _writeResult(res, 'user_tags', [get('SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)])]);++async function grantUserTag(userId, tagId, source = 'shop') {++ return await transaction(async () => {++ const res = await run('INSERT INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', [userId, String(tagId), source || 'shop']);++ return _writeResult(res, 'user_tags', [await get('SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)])]);});}--function revokeUserTag(userId, tagId) {-- return transaction(() => {-- const gone = all('SELECT id FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);-- const res = run('DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);++async function revokeUserTag(userId, tagId) {++ return await transaction(async () => {++ const gone = await all('SELECT id FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);++ const res = await run('DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);return _writeResult(res, 'user_tags', [], gone.map((r) => ({ id: r.id })));});}// Chat AI (Live's server/ai/chat-ai.js): rolling summaries and the append-only timeline.--function upsertChatAiSummary(sfx) {++async function upsertChatAiSummary(sfx) {const {scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',message_count = 0, window_message_count = 0, last_message_id = 0,window_label = '', window_start = null, window_end = null,} = sfx || {};-- return transaction(() => {-- const res = run(++ return await transaction(async () => {++ const res = await run(`INSERT INTO chat_ai_summaries-- (scope, subject_id, window, overview, memory_json, timeline_json, message_count,++ (scope, subject_id, "window", overview, memory_json, timeline_json, message_count,window_message_count, last_message_id, window_label, window_start, window_end, updated_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)-- ON CONFLICT(scope, subject_id, window) DO UPDATE SET++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ov_now())++ ON CONFLICT(scope, subject_id, "window") DO UPDATE SEToverview = excluded.overview,memory_json = excluded.memory_json,timeline_json = excluded.timeline_json,@@@ -1209,29 +1212,29 @@ function upsertChatAiSummary(sfx) {window_label = excluded.window_label,window_start = excluded.window_start,window_end = excluded.window_end,-- updated_at = CURRENT_TIMESTAMP`,++ updated_at = ov_now()`,[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,window_message_count, last_message_id, window_label, window_start, window_end]);-- return _writeResult(res, 'chat_ai_summaries', [get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?', [scope, subject_id || 0, window])]);++ return _writeResult(res, 'chat_ai_summaries', [await get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND "window" = ?', [scope, subject_id || 0, window])]);});}--function addChatTimelineEvents(scope, subjectId, events) {++async function addChatTimelineEvents(scope, subjectId, events) {if (!Array.isArray(events) || !events.length) return _writeResult(0, 'chat_timeline_events');-- return transaction(() => {++ return await transaction(async () => {let n = 0;const ids = [];for (const e of events) {if (!e || !e.label || !e.ts) continue;try {-- const res = run('INSERT OR IGNORE INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?)',++ const res = await run('INSERT INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',[scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400)]);if (res.changes) ids.push(Number(res.lastInsertRowid));n++; // Live counts every event it tried, the duplicates included} catch { /* */ }}-- return _writeResult(n, 'chat_timeline_events', ids.map((id) => get('SELECT * FROM chat_timeline_events WHERE id = ?', [id])));++ return _writeResult(n, 'chat_timeline_events', (await Promise.all(ids.map(async (id) => await get('SELECT * FROM chat_timeline_events WHERE id = ?', [id])))));});}@@@ -1240,42 +1243,42 @@ function addChatTimelineEvents(scope, subjectId, events) {// the ctx_* projections here (Live's rows are not in this database).const _CHAT_AI_WHERE = `cm.is_deleted = 0 AND cm.message_type != 'system'AND COALESCE(cm.source_platform,'') != 'ai'-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;--function getChatAiSummary(scope, subjectId, window) {-- return get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?',++async function getChatAiSummary(scope, subjectId, window) {++ return await get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND "window" = ?',[scope, subjectId || 0, window]) || null;}--function getChatAiSummaries(scope, subjectId) {-- return all('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? ORDER BY window',++async function getChatAiSummaries(scope, subjectId) {++ return await all('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? ORDER BY "window"',[scope, subjectId || 0]);}// Paginated + searchable timeline browse. `before` = epoch ms (exclusive upper bound); `q`// filters label/detail; `since` = epoch ms lower bound (for period jumps). Newest first.--function getChatTimelineEvents({ scope = 'global', subjectId = 0, before = null, since = null, q = null, limit = 25 } = {}) {++async function getChatTimelineEvents({ scope = 'global', subjectId = 0, before = null, since = null, q = null, limit = 25 } = {}) {const conds = ['scope = ?', 'subject_id = ?'];const params = [scope, subjectId || 0];-- if (before) { conds.push("ts < datetime(?, 'unixepoch')"); params.push(Math.floor(before / 1000)); }-- if (since) { conds.push("ts >= datetime(?, 'unixepoch')"); params.push(Math.floor(since / 1000)); }-- if (q && String(q).trim()) { const like = '%' + String(q).trim().slice(0, 60) + '%'; conds.push('(label LIKE ? OR detail LIKE ?)'); params.push(like, like); }++ if (before) { conds.push("ts < to_char(to_timestamp(?) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')"); params.push(Math.floor(before / 1000)); }++ if (since) { conds.push("ts >= to_char(to_timestamp(?) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')"); params.push(Math.floor(since / 1000)); }++ if (q && String(q).trim()) { const like = '%' + String(q).trim().slice(0, 60) + '%'; conds.push('(label ILIKE ? OR detail ILIKE ?)'); params.push(like, like); }params.push(Math.min(60, Math.max(1, limit)));try {-- return all(`SELECT id, ts, label, detail FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];++ return await all(`SELECT id, ts, label, detail FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];} catch { return []; }}--function getMaxChatMessageId() {-- return get('SELECT MAX(id) AS m FROM chat_messages')?.m || 0;++async function getMaxChatMessageId() {++ return (await get('SELECT MAX(id) AS m FROM chat_messages'))?.m || 0;}// Count analyzable messages newer than a high-water id (optionally for one user).--function countChatMessagesSince(afterId, userId = null) {++async function countChatMessagesSince(afterId, userId = null) {let sql = `SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${_CHAT_AI_WHERE} AND cm.id > ?`;const params = [afterId || 0];if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }-- return get(sql, params)?.c || 0;++ return (await get(sql, params))?.c || 0;}// Fetch analyzable messages for AI batching (with the channel/broadcaster label).--function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {++async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {let sql = `SELECT cm.id, cm.user_id, cm.username, cm.message, cm.message_type, cm.timestamp,cm.stream_id, cm.channel_user_id, cm.is_global,ch.username AS channel_username, ch.display_name AS channel_display@@@ -1288,41 +1291,41 @@ function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, lif (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;params.push(Math.max(1, Math.min(2000, limit)));-- const rows = all(sql, params);++ const rows = await all(sql, params);return order === 'desc' ? rows.reverse() : rows;}// Timestamp of the Nth-most-recent analyzable message — drives the adaptive overview window.--function getNthRecentChatTs(n, userId = null) {++async function getNthRecentChatTs(n, userId = null) {let sql = `SELECT cm.timestamp AS ts FROM chat_messages cm WHERE ${_CHAT_AI_WHERE}`;const params = [];if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }sql += ' ORDER BY cm.id DESC LIMIT 1 OFFSET ?';params.push(Math.max(0, (n | 0) - 1));-- return get(sql, params)?.ts || null;++ return (await get(sql, params))?.ts || null;}// Users with enough new chat activity (or a stale summary) to warrant an AI refresh.--function getUsersNeedingChatAi({ threshold = 15, staleCutoffIso, sinceTs, limit = 3 } = {}) {++async function getUsersNeedingChatAi({ threshold = 15, staleCutoffIso, sinceTs, limit = 3 } = {}) {const sql = `SELECT cm.user_id AS uid,MAX(cm.id) AS max_id,-- SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS new_msgs,++ CAST(SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS BIGINT) AS new_msgs,COALESCE(cs.last_message_id, 0) AS hw,cs.updated_at AS last_updateFROM chat_messages cmLEFT JOIN chat_ai_summaries cs-- ON cs.scope = 'user' AND cs.subject_id = cm.user_id AND cs.window = 'rolling'++ ON cs.scope = 'user' AND cs.subject_id = cm.user_id AND cs."window" = 'rolling'WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NOT NULL AND cm.timestamp >= ?-- GROUP BY cm.user_id-- HAVING new_msgs > 0-- AND ( new_msgs >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )++ GROUP BY cm.user_id, cs.last_message_id, cs.updated_at++ HAVING SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) > 0++ AND ( SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )ORDER BY (cs.updated_at IS NULL) DESC, new_msgs DESCLIMIT ?`;-- return all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);++ return await all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);}// Relay messages for AI batching (mirrors getChatMessagesForAi).--function getRelayChatMessagesForAi({ platform, rawUsername, sinceTs = null, limit = 300, order = 'asc' } = {}) {++async function getRelayChatMessagesForAi({ platform, rawUsername, sinceTs = null, limit = 300, order = 'asc' } = {}) {let sql = `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.source_platformFROM chat_messages cmWHERE ${_CHAT_AI_WHERE} AND ${_RELAY_MATCH}`;@@@ -1330,18 +1333,18 @@ function getRelayChatMessagesForAi({ platform, rawUsername, sinceTs = null, limiif (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;params.push(Math.max(1, Math.min(2000, limit)));-- const rows = all(sql, params);++ const rows = await all(sql, params);return order === 'desc' ? rows.reverse() : rows;}// Relay users with new activity since their last AI summary (or never summarised).--function getRelayUsersNeedingChatAi({ lookbackIso, threshold = 8, limit = 2 } = {}) {-- return all(`-- SELECT r.rowid AS id, r.platform, r.username, r.display_name, r.message_count, r.last_seen,++async function getRelayUsersNeedingChatAi({ lookbackIso, threshold = 8, limit = 2 } = {}) {++ return await all(`++ SELECT r.id, r.platform, r.username, r.display_name, r.message_count, r.last_seen,cs.updated_at AS last_updateFROM relay_users rLEFT JOIN chat_ai_summaries cs-- ON cs.scope = 'relay' AND cs.window = 'rolling' AND cs.subject_id = r.rowid++ ON cs.scope = 'relay' AND cs."window" = 'rolling' AND cs.subject_id = r.idWHERE r.last_seen >= ?AND r.message_count >= ?AND (cs.updated_at IS NULL OR cs.updated_at < r.last_seen)@@@ -1350,7 +1353,7 @@ function getRelayUsersNeedingChatAi({ lookbackIso, threshold = 8, limit = 2 } =}// Anon messages for AI batching (mirrors getChatMessagesForAi / getRelayChatMessagesForAi).--function getAnonChatMessagesForAi({ anonId, sinceTs = null, limit = 300, order = 'asc' } = {}) {++async function getAnonChatMessagesForAi({ anonId, sinceTs = null, limit = 300, order = 'asc' } = {}) {let sql = `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestampFROM chat_messages cmWHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NULL AND cm.anon_id = ?`;@@@ -1358,44 +1361,48 @@ function getAnonChatMessagesForAi({ anonId, sinceTs = null, limit = 300, order =if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;params.push(Math.max(1, Math.min(2000, limit)));-- const rows = all(sql, params);++ const rows = await all(sql, params);return order === 'desc' ? rows.reverse() : rows;}// Anons with enough new chat activity (or a stale summary) to warrant an AI refresh.--function getAnonsNeedingChatAi({ threshold = 12, staleCutoffIso, sinceTs, limit = 2 } = {}) {++async function getAnonsNeedingChatAi({ threshold = 12, staleCutoffIso, sinceTs, limit = 2 } = {}) {const sql = `SELECT cm.anon_id AS anon_id,MAX(cm.id) AS max_id,-- SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS new_msgs++ CAST(SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS BIGINT) AS new_msgsFROM chat_messages cmLEFT JOIN chat_ai_summaries cs-- ON cs.scope = 'anon' AND cs.window = 'rolling'-- AND cs.subject_id = CAST(SUBSTR(cm.anon_id, 5) AS INTEGER)++ ON cs.scope = 'anon' AND cs."window" = 'rolling'++ AND cs.subject_id = CASE WHEN cm.anon_id ~ '^anon[0-9]{1,15}$' THEN CAST(SUBSTR(cm.anon_id, 5) AS BIGINT) ENDWHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NULL AND cm.anon_id IS NOT NULL-- AND cm.anon_id LIKE 'anon%' AND cm.timestamp >= ?-- GROUP BY cm.anon_id-- HAVING new_msgs > 0-- AND ( new_msgs >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )++ AND cm.anon_id ILIKE 'anon%' AND cm.timestamp >= ?++ GROUP BY cm.anon_id, cs.last_message_id, cs.updated_at++ HAVING SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) > 0++ AND ( SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )ORDER BY (cs.updated_at IS NULL) DESC, new_msgs DESCLIMIT ?`;-- return all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);++ return await all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);}// ── Meta ─────────────────────────────────────────────────────--function getMeta(key) { return get('SELECT value FROM chat_meta WHERE key = ?', [key])?.value ?? null; }--function setMeta(key, value) { return run('INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value == null ? null : String(value)]); }++async function getMeta(key) { return (await get('SELECT value FROM chat_meta WHERE key = ?', [key]))?.value ?? null; }++async function setMeta(key, value) { return await run('INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value == null ? null : String(value)]); }module.exports = {CHAT_TABLES,++ moderationRow,++ chatMessageRow,++ openDb,getDb,initDb,-- installMirrorTriggers,++ setDb,close,run,get,all,transaction,++ tx: transaction,subjectFor,getMeta,setMeta,ddeleted file mode 100644@@@ -1,514 +0,0 @@------CREATE TABLE IF NOT EXISTS chat_messages (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- stream_id INTEGER, -- Live stream (session) id-- user_id INTEGER, -- Live user id; 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, -- the broadcaster whose channel room this is-- subject_id TEXT -- Network subject of the author (usr_…), when known--);--CREATE INDEX IF NOT EXISTS idx_chat_stream_id ON chat_messages(stream_id);--CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_messages(timestamp);--CREATE INDEX IF NOT EXISTS idx_chat_user_id ON chat_messages(user_id);--CREATE INDEX IF NOT EXISTS idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp);--CREATE INDEX IF NOT EXISTS idx_chat_stream_ts ON chat_messages(stream_id, timestamp);--CREATE INDEX IF NOT EXISTS idx_chat_autodelete ON chat_messages(auto_delete_at);--CREATE INDEX IF NOT EXISTS idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted);--CREATE INDEX IF NOT EXISTS idx_chat_page_live ON chat_messages(id DESC) WHERE is_deleted = 0;--CREATE INDEX IF NOT EXISTS idx_chat_anon ON chat_messages(anon_id);----CREATE TABLE IF NOT EXISTS 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--);----CREATE TABLE IF NOT EXISTS 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,-- subject_id TEXT,-- UNIQUE(conversation_id, user_id),-- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE--);--CREATE INDEX IF NOT EXISTS idx_dm_participants_conv ON dm_participants(conversation_id);--CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id);----CREATE TABLE IF NOT EXISTS 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,-- sender_subject_id TEXT,-- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE--);--CREATE INDEX IF NOT EXISTS idx_dm_messages_conv ON dm_messages(conversation_id, created_at);--CREATE INDEX IF NOT EXISTS idx_dm_messages_sender ON dm_messages(sender_id);----CREATE TABLE IF NOT EXISTS dm_blocks (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- blocker_id INTEGER NOT NULL,-- blocked_id INTEGER NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- blocker_subject_id TEXT,-- UNIQUE(blocker_id, blocked_id)--);--CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocker ON dm_blocks(blocker_id);--CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocked ON dm_blocks(blocked_id);----CREATE TABLE IF NOT EXISTS tts_voice_overrides (-- identity_key TEXT PRIMARY KEY,-- voice TEXT,-- pitch INTEGER,-- speed INTEGER,-- gap INTEGER DEFAULT 0,-- set_by INTEGER,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP--);----CREATE TABLE IF NOT EXISTS 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, -- on-disk path of the audio file-- 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 '',-- created_by_subject_id TEXT-- -- Multiple sounds may share a command; playback picks one at random.-- -- media_url / media_asset_id stay on Live's copy (its asset-sync mirrors files to Media).--);--CREATE INDEX IF NOT EXISTS idx_channel_sounds_owner ON channel_sounds(channel_owner_id);--CREATE INDEX IF NOT EXISTS idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);----CREATE TABLE IF NOT EXISTS relay_users (-- platform TEXT NOT NULL,-- username TEXT NOT NULL,-- display_name TEXT,-- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,-- message_count INTEGER DEFAULT 0,-- PRIMARY KEY (platform, username)--);----CREATE TABLE IF NOT EXISTS 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--);--CREATE INDEX IF NOT EXISTS idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform);----CREATE TABLE IF NOT EXISTS 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--);--CREATE INDEX IF NOT EXISTS idx_pending_ip_channel ON pending_ip_messages(channel_id, status);----CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_sfc_channel ON stream_first_chats(channel_user_id);----CREATE TABLE IF NOT EXISTS 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,-- actor_subject_id TEXT--);--CREATE INDEX IF NOT EXISTS idx_mod_actions_created ON moderation_actions(created_at DESC);--CREATE INDEX IF NOT EXISTS idx_mod_actions_actor ON moderation_actions(actor_user_id);--CREATE INDEX IF NOT EXISTS idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);------CREATE TABLE IF NOT EXISTS 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)--);--CREATE INDEX IF NOT EXISTS idx_channel_mods_channel ON channel_moderators(channel_id);--CREATE INDEX IF NOT EXISTS idx_channel_mods_user ON channel_moderators(user_id);----CREATE TABLE IF NOT EXISTS 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,-- custom_sounds_enabled INTEGER DEFAULT 1,-- max_sound_seconds INTEGER DEFAULT 10,-- uploads_mods_only INTEGER DEFAULT 0,-- mods_can_edit_about INTEGER DEFAULT 0,-- donation_sound_url TEXT,-- donation_sound_mime TEXT,-- goal_sound_url TEXT,-- goal_sound_mime TEXT,-- emote_scale INTEGER DEFAULT 100,-- 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,-- sub_only INTEGER DEFAULT 0 -- sub-only chat: active subscribers and the room's moderators--);----CREATE TABLE IF NOT EXISTS 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,-- media_url TEXT,-- media_asset_id INTEGER--);--CREATE INDEX IF NOT EXISTS idx_emotes_user ON emotes(user_id);--CREATE INDEX IF NOT EXISTS idx_emotes_channel_owner ON emotes(channel_owner_id);--CREATE UNIQUE INDEX IF NOT EXISTS idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code);----CREATE TABLE IF NOT EXISTS user_tags (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- tag_id TEXT NOT NULL,-- source TEXT DEFAULT 'shop',-- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(user_id, tag_id)--);--CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id);----CREATE TABLE IF NOT EXISTS 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 TABLE IF NOT EXISTS 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 UNIQUE INDEX IF NOT EXISTS idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label);------CREATE TABLE IF NOT EXISTS ctx_users (-- id INTEGER PRIMARY KEY,-- username TEXT,-- display_name TEXT,-- avatar_url TEXT,-- profile_color TEXT,-- role TEXT DEFAULT 'user',-- is_banned INTEGER DEFAULT 0,-- ban_reason TEXT,-- is_owner INTEGER DEFAULT 0,-- created_at DATETIME,-- subject_id TEXT,-- synced_at INTEGER--);--CREATE INDEX IF NOT EXISTS idx_ctx_users_username ON ctx_users(username COLLATE NOCASE);--CREATE INDEX IF NOT EXISTS idx_ctx_users_display ON ctx_users(display_name COLLATE NOCASE);----CREATE TABLE IF NOT EXISTS ctx_streams (-- id INTEGER PRIMARY KEY,-- user_id INTEGER,-- channel_id INTEGER,-- managed_stream_id INTEGER,-- title TEXT,-- is_live INTEGER DEFAULT 0,-- started_at DATETIME,-- ended_at DATETIME,-- created_at DATETIME,-- synced_at INTEGER--);--CREATE INDEX IF NOT EXISTS idx_ctx_streams_user ON ctx_streams(user_id, id);--CREATE INDEX IF NOT EXISTS idx_ctx_streams_channel ON ctx_streams(channel_id);--CREATE INDEX IF NOT EXISTS idx_ctx_streams_managed ON ctx_streams(managed_stream_id);----CREATE TABLE IF NOT EXISTS ctx_managed_streams (-- id INTEGER PRIMARY KEY,-- user_id INTEGER,-- slug TEXT,-- title TEXT,-- sort_order INTEGER DEFAULT 0,-- created_at DATETIME,-- synced_at INTEGER--);--CREATE INDEX IF NOT EXISTS idx_ctx_ms_user ON ctx_managed_streams(user_id);----CREATE TABLE IF NOT EXISTS ctx_channels (-- id INTEGER PRIMARY KEY,-- user_id INTEGER UNIQUE,-- title TEXT,-- emote_sources TEXT, -- the channel's emote source switches (Live's channels.emote_sources)-- synced_at INTEGER--);----CREATE TABLE IF NOT EXISTS ctx_sync (-- key TEXT PRIMARY KEY,-- value TEXT,-- updated_at INTEGER--);----CREATE TABLE IF NOT EXISTS events_outbox (-- seq INTEGER PRIMARY KEY AUTOINCREMENT,-- event_id TEXT NOT NULL UNIQUE,-- event_type TEXT NOT NULL,-- event TEXT NOT NULL,-- created_at TEXT NOT NULL,-- sent_at TEXT,-- attempts INTEGER DEFAULT 0,-- last_error TEXT--);--CREATE INDEX IF NOT EXISTS idx_events_outbox_unsent ON events_outbox(sent_at, seq);----CREATE TABLE IF NOT EXISTS live_mirror_outbox (-- seq INTEGER PRIMARY KEY AUTOINCREMENT,-- tbl TEXT NOT NULL,-- op TEXT NOT NULL CHECK(op IN ('upsert', 'delete')),-- pk TEXT NOT NULL,-- created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))--);----CREATE TABLE IF NOT EXISTS bridge_applied (-- key TEXT PRIMARY KEY,-- result TEXT,-- applied_at INTEGER NOT NULL--);----CREATE TABLE IF NOT EXISTS audio_requests (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- room TEXT NOT NULL,-- stream_id INTEGER,-- channel_user_id INTEGER,-- kind TEXT NOT NULL CHECK(kind IN ('tts', 'channel-sound', 'soundboard')),-- state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'playing', 'played', 'skipped', 'failed')),-- requested_by TEXT,-- identity_key TEXT,-- label TEXT,-- payload TEXT NOT NULL DEFAULT '{}',-- dedupe_key TEXT,-- attempts INTEGER NOT NULL DEFAULT 0,-- duration_ms INTEGER,-- error TEXT,-- actor TEXT,-- created_at INTEGER NOT NULL,-- started_at INTEGER,-- finished_at INTEGER,-- UNIQUE(room, dedupe_key)--);--CREATE INDEX IF NOT EXISTS idx_audio_requests_room_state ON audio_requests(room, state, id);----CREATE TABLE IF NOT EXISTS bridge_refs (-- boot TEXT NOT NULL,-- ref INTEGER NOT NULL,-- id INTEGER NOT NULL,-- at INTEGER NOT NULL,-- PRIMARY KEY (boot, ref)--);----CREATE TABLE IF NOT EXISTS import_hold (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- source_table TEXT NOT NULL,-- source_pk TEXT NOT NULL,-- reason TEXT NOT NULL,-- row_json TEXT NOT NULL,-- held_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- UNIQUE(source_table, source_pk)--);----CREATE TABLE IF NOT EXISTS import_runs (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- live_db TEXT,-- dry_run INTEGER DEFAULT 0,-- counts TEXT,-- started_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- finished_at DATETIME--);----CREATE TABLE IF NOT EXISTS chat_meta (-- key TEXT PRIMARY KEY,-- value TEXT--);----CREATE TABLE IF NOT EXISTS deploy_releases (-- head TEXT PRIMARY KEY,-- message_id INTEGER,-- first_via TEXT NOT NULL CHECK(first_via IN ('bridge', 'events')),-- bridge_at INTEGER,-- event_at INTEGER,-- event_id TEXT,-- commit_count INTEGER NOT NULL DEFAULT 0,-- created_at INTEGER NOT NULL--);----CREATE TABLE IF NOT EXISTS chat_event_inbox (-- consumer TEXT NOT NULL,-- event_id TEXT NOT NULL,-- processed_at INTEGER NOT NULL,-- PRIMARY KEY (consumer, event_id)--);----CREATE TABLE IF NOT EXISTS calls (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- kind TEXT NOT NULL CHECK(kind IN ('channel', 'direct', 'stream')),-- state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'ringing', 'active', 'ended', 'missed', 'declined', 'failed')),-- channel_id TEXT NOT NULL,-- stream_id INTEGER,-- created_by INTEGER,-- created_by_subject TEXT,-- target_user_id INTEGER,-- target_subject TEXT,-- end_reason TEXT,-- created_at INTEGER NOT NULL,-- started_at INTEGER,-- answered_at INTEGER,-- ended_at INTEGER--);--CREATE INDEX IF NOT EXISTS idx_calls_channel_state ON calls(channel_id, state);--CREATE INDEX IF NOT EXISTS idx_calls_open ON calls(state) WHERE state IN ('pending', 'ringing', 'active');@@@ -35,7 +35,7 @@const express = require('express');const { http } = require('openvibe-contracts');--const { parseDelivery, createInbox } = require('openvibe-sdk/events');++const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');const db = require('../db/database');const { viaProxy } = require('../net/service-auth');const deployNotice = require('../chat/deploy-notice');@@@ -59,8 +59,7 @@ const INBOX_KEEP_MS = 35 * 24 * 3600 * 1000; // Events keeps events 30 day*/function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now(), releaseMaxAgeMs = deployNotice.RELEASE_MAX_AGE_MS, log = console } = {}) {const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);-- const inbox = createInbox(db.getDb(), { table: INBOX_TABLE, now });-- inbox.ensureSchema();++ const inbox = createPgInbox(db.getDb(), { table: INBOX_TABLE, now }); // the table is in migrations/0001_initial.sqlconst stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null, last_type: null, last_outcome: null, last_error: null };/**@@@ -71,7 +70,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()if (event.event_type === 'live.release.deployed') {const rel = deployNotice.releaseFrom(event, { now: now(), maxAgeMs: releaseMaxAgeMs });if (typeof rel === 'string') return rel;-- return () => deployNotice.applyReleaseEvent({ db, chatServer, event, now: now(), maxAgeMs: releaseMaxAgeMs, log });++ return async () => await deployNotice.applyReleaseEvent({ db, chatServer, event, now: now(), maxAgeMs: releaseMaxAgeMs, log });}if (event.event_type === 'network.module.updated') {if (event.source !== 'network') return 'ignored:source';@@@ -93,13 +92,13 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()const subject = p.subject && p.subject.id;const ms = Date.parse(p.valid_after);if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';-- return () => {-- const moved = revocations.record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null, now());++ return async () => {++ const moved = await revocations.record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null, now());if (!moved) return 'unchanged';return {outcome: 'revoked',-- after: () => {-- for (const r of db.all('SELECT id FROM ctx_users WHERE subject_id = ?', [subject])) ctx.invalidateUser(r.id);++ after: async () => {++ for (const r of await db.all('SELECT id FROM ctx_users WHERE subject_id = ?', [subject])) ctx.invalidateUser(r.id);const closed = chatServer ? chatServer.revokeSubject(subject, ms) : 0;if (closed) log.log && log.log(`[Events consumer] ${p.reason || 'revoked'}: closed ${closed} socket(s)`);},@@@ -111,7 +110,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()if (event.source !== 'network') return 'ignored:source';const p = networkBlocks.payloadOf(event);if (!p) return 'ignored:payload';-- return () => (networkBlocks.apply(p, now()) ? (p.active ? 'blocked' : 'unblocked') : 'unchanged');++ return async () => (await networkBlocks.apply(p, now()) ? (p.active ? 'blocked' : 'unblocked') : 'unchanged');}if (event.event_type === 'network.subject.merged') {// Two accounts became one (ADR-029): the folded-in account's messages, DMs, rooms and blocks are the survivor's.@@@ -119,7 +118,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()const merge = require('../chat/subject-merge');const p = merge.payloadOf(event);if (!p) return 'ignored:payload';-- return () => merge.apply(p);++ return async () => await merge.apply(p);}if (event.event_type === 'vip.membership.changed') {// A membership started, lapsed or was revoked: drop that member's cached badge answers for@@@ -131,24 +130,24 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()}/** Apply one envelope. Returns { duplicate, outcome, detail? }. Throws only on a storage failure. */-- function apply(event) {++ async function apply(event) {const work = plan(event);if (typeof work === 'string') return { duplicate: false, outcome: work };let after = null;-- const r = inbox.once(CONSUMER, event.event_id, () => {-- const out = work();++ const r = await inbox.once(CONSUMER, event.event_id, async () => {++ const out = await work();if (typeof out === 'string') return { outcome: out };after = out.after || null;return { outcome: out.outcome, detail: out.detail };});if (r.duplicate) return { duplicate: true, outcome: null };// Side effects outside the database run only after the commit, once per event.-- if (after) { try { after(); } catch (err) { log.warn('[Events consumer] after-commit step failed:', err.message); } }++ if (after) { try { await after(); } catch (err) { log.warn('[Events consumer] after-commit step failed:', err.message); } }return { duplicate: false, outcome: r.result.outcome, ...(r.result.detail ? { detail: r.result.detail } : {}) };}const router = express.Router();-- router.post('/', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {++ router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {const ctx = http.requestContext(req.headers);const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });if (viaProxy(req)) return problem(403, 'chat.internal_only', 'internal route');@@@ -183,7 +182,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()}let out;try {-- out = apply(event);++ out = await apply(event);} catch (err) {// Not acknowledged: the inbox claim rolled back with the change, and Events retries.stats.failed++;@@@ -202,13 +201,13 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()});/** Drop inbox receipts older than Events' retention (nothing older can be redelivered). */-- function prune() {-- return db.run(`DELETE FROM ${INBOX_TABLE} WHERE processed_at < ?`, [now() - INBOX_KEEP_MS]).changes;++ async function prune() {++ return (await db.run(`DELETE FROM ${INBOX_TABLE} WHERE processed_at < ?`, [now() - INBOX_KEEP_MS])).changes;}let timer = null;function start() {if (timer) return;-- timer = setInterval(() => { try { prune(); } catch (err) { log.warn('[Events consumer] inbox prune:', err.message); } }, 6 * 3600 * 1000);++ timer = setInterval(async () => { try { await prune(); } catch (err) { log.warn('[Events consumer] inbox prune:', err.message); } }, 6 * 3600 * 1000);if (timer.unref) timer.unref();}function stop() { if (timer) clearInterval(timer); timer = null; }@@@ -28,7 +28,7 @@ function actorFor(subjectId) {}/** Add one envelope to the outbox. Call inside the transaction that makes the change. */--function enqueue({ event_type, subject, payload, visibility = 'internal', priority = 'important', actorSubject = null }) {++async function enqueue({ event_type, subject, payload, visibility = 'internal', priority = 'important', actorSubject = null }) {const db = require('../db/database');const ms = Date.now();const env = {@@@ -45,7 +45,7 @@ function enqueue({ event_type, subject, payload, visibility = 'internal', priori};const v = validate('events.event-envelope@1', env);if (!v.valid) throw new Error(`outbox: invalid envelope for ${event_type}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);-- db.run('INSERT INTO events_outbox (event_id, event_type, event, created_at) VALUES (?, ?, ?, ?)', [env.event_id, event_type, JSON.stringify(env), env.timestamp]);++ await db.run('INSERT INTO events_outbox (event_id, event_type, event, created_at) VALUES (?, ?, ?, ?)', [env.event_id, event_type, JSON.stringify(env), env.timestamp]);return env;}@@@ -63,9 +63,9 @@ function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log =let timer = null;let busy = false;-- function markFailed(rows, message) {++ async function markFailed(rows, message) {const mark = db.getDb().prepare('UPDATE events_outbox SET attempts = attempts + 1, last_error = ? WHERE seq = ?');-- db.transaction(() => { for (const r of rows) mark.run(String(message).slice(0, 500), r.seq); });++ await db.tx(async () => { for (const r of rows) await mark.run(String(message).slice(0, 500), r.seq); });log.warn(`[Chat] outbox relay: ${rows.length} event(s) not published: ${message}`);}@@@ -76,7 +76,7 @@ function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log =let sent = 0;try {for (;;) {-- const rows = db.all('SELECT seq, event_id, event FROM events_outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100');++ const rows = await db.all('SELECT seq, event_id, event FROM events_outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100');if (!rows.length) break;let res;try {@@@ -87,18 +87,18 @@ function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log =signal: AbortSignal.timeout(10000),});} catch (e) {-- markFailed(rows, e.message);++ await markFailed(rows, e.message);break;}if (res.status === 401) tokens.invalidate();if (!res.ok) {const text = await res.text().catch(() => '');-- markFailed(rows, `${res.status} ${text.slice(0, 300)}`);++ await markFailed(rows, `${res.status} ${text.slice(0, 300)}`);break;}const at = new Date().toISOString();const mark = db.getDb().prepare('UPDATE events_outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?');-- db.transaction(() => { for (const r of rows) mark.run(at, r.seq); });++ await db.tx(async () => { for (const r of rows) await mark.run(at, r.seq); });sent += rows.length;}} finally {@@@ -120,11 +120,11 @@ function startAtBoot({ config, port, fetchImpl, log = console, retryMs = BOOT_REconst next = retryMs[i + 1];log.warn(`[Events] subscriptions not ensured: ${err.message}${next != null ? `; retrying in ${Math.round(next / 1000)} s` : '; giving up until the next restart (or run scripts/subscribe-events.js)'}`);if (next == null || stopped) return resolve(null);-- timer = setTimeout(() => attempt(i + 1), next);++ timer = setTimeout(async () => await attempt(i + 1), next);if (timer.unref) timer.unref();}};-- timer = setTimeout(() => attempt(0), retryMs[0] || 0);++ timer = setTimeout(async () => await attempt(0), retryMs[0] || 0);if (timer.unref) timer.unref();});return { done, stop() { stopped = true; if (timer) clearTimeout(timer); finish(null); } };@@@ -27,10 +27,20 @@ const { limits } = require('./net/actor-limits');const { createValkey } = require('openvibe-sdk/valkey');const { createApp } = require('./app');++let rejectionsLogged = false;++async function start() {-- console.log(`[Chat] OpenVibe.Chat starting (db ${config.dbPath}, Live ${config.live.internalUrl}, mirror ${config.live.mirror ? 'on' : 'off'}, events ${config.events.url || 'off'}, consumer ${config.events.secrets.length ? 'on' : 'off'})`);-- // Mirror capture only when the mirror is on: before the cutover (rehearsals) nothing queues.-- db.initDb({ captureMirror: config.live.mirror });++ // Background loops (timers, socket close handlers) await the database now; a transient failure there (a++ // PgBouncer restart, a statement timeout) is logged, and the loop's next tick tries again, instead of ending++ // the process as an unhandled rejection would.++ if (!rejectionsLogged) {++ rejectionsLogged = true;++ process.on('unhandledRejection', (err) => console.error('[Chat] unhandled rejection:', (err && err.stack) || err));++ }++ console.log(`[Chat] OpenVibe.Chat starting (db ${config.db.url ? 'postgresql' : 'pglite'}, Live ${config.live.internalUrl}, mirror ${config.live.mirror ? 'on' : 'off'}, events ${config.events.url || 'off'}, consumer ${config.events.secrets.length ? 'on' : 'off'})`);++ // PostgreSQL (DATABASE_URL; migrations first, as the owner) or, outside production, an embedded PGlite.++ // The Live mirror's capture is the migration's triggers; with the mirror off its queue is emptied.++ await db.initDb();// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this// process, as before. openvibe-sdk/valkey's createValkey returns null when the URL is unset.@@@ -69,7 +79,7 @@ async function start() {stopping = true;console.log('[Chat] shutting down');try {-- chatServer.broadcastAll({++ await chatServer.broadcastAll({type: 'server_restart',message: '⚙️ Chat server restarting — you will be reconnected automatically.',timestamp: new Date().toISOString(),@@@ -84,8 +94,8 @@ async function start() {try { if (valkey) await valkey.close(); } catch { /* */ }try { await Promise.race([mirror.flush(), new Promise((r) => setTimeout(r, 3000))]); } catch { /* */ }try { chatServer.close(); } catch { /* */ }-- try { callServer.close(); } catch { /* */ }-- server.close(() => { db.close(); process.exit(0); });++ try { await callServer.close(); } catch { /* */ }++ server.close(async () => { await db.close(); process.exit(0); });setTimeout(() => process.exit(0), 5000).unref();};process.on('SIGTERM', shutdown);@@@ -149,7 +149,7 @@ async function call(method, path, body, scope, retried = false) {stats.failures++; stats.lastError = err.message;throw new LiveError(0, `Live unreachable: ${err.message}`);}-- if (res.status === 401 && !retried) { serviceAuth.invalidate(config.live.audience, scope); return call(method, path, body, scope, true); }++ if (res.status === 401 && !retried) { serviceAuth.invalidate(config.live.audience, scope); return await call(method, path, body, scope, true); }if (res.status === 304) return { notModified: true };const data = await res.json().catch(() => null);if (!res.ok) {@@@ -158,8 +158,8 @@ async function call(method, path, body, scope, retried = false) {}return data;}--const read = (path, body) => { stats.reads++; return call(body === undefined ? 'GET' : 'POST', `/internal/chat-context${path}`, body, SCOPE_READ); };--const effect = (name, body) => { stats.effects++; return call('POST', `/internal/chat-effects/${name}`, body || {}, SCOPE_WRITE); };++const read = async (path, body) => { stats.reads++; return await call(body === undefined ? 'GET' : 'POST', `/internal/chat-context${path}`, body, SCOPE_READ); };++const effect = async (name, body) => { stats.effects++; return await call('POST', `/internal/chat-effects/${name}`, body || {}, SCOPE_WRITE); };/** Fire-and-forget effect: logged, never thrown. */function fire(name, body) { effect(name, body).catch((err) => console.warn(`[LiveContext] ${name}: ${err.message}`)); }@@@ -221,50 +221,51 @@ function emoteSources(raw) {return out;}--function upsertRows(table, cols, rows) {++async function upsertRows(table, cols, rows) {if (!rows || !rows.length) return 0;const sql = `INSERT INTO ${table} (${cols.join(', ')}, synced_at) VALUES (${cols.map(() => '?').join(', ')}, ?)ON CONFLICT(id) DO UPDATE SET ${cols.filter((c) => c !== 'id').map((c) => `${c} = excluded.${c}`).join(', ')}, synced_at = excluded.synced_at`;const st = db.getDb().prepare(sql);const now = Date.now();-- db.transaction(() => {++ await db.tx(async () => {for (const r of rows) {if (!r || r.id == null) continue;-- st.run(...cols.map((c) => (r[c] === undefined ? null : (typeof r[c] === 'boolean' ? (r[c] ? 1 : 0) : r[c]))), now);++ await st.run(...cols.map((c) => (r[c] === undefined ? null : (typeof r[c] === 'boolean' ? (r[c] ? 1 : 0) : r[c]))), now);}});return rows.length;}/** Store a user as Live sent it (only the projection columns are kept). */--function upsertUser(row) {++async function upsertUser(row) {if (!row || row.id == null) return;-- upsertRows('ctx_users', USER_COLS, [row]);++ await upsertRows('ctx_users', USER_COLS, [row]);}const userSelect = 'SELECT * FROM ctx_users';--function getUserById(id) { if (!id) return null; const u = db.get(`${userSelect} WHERE id = ?`, [id]) || null; if (!u) ensureUsers([id]).catch(() => {}); return u; }--function getUserByUsername(name) { return name ? (db.get(`${userSelect} WHERE username = ? COLLATE NOCASE`, [String(name)]) || null) : null; }--function getUserByDisplayName(name) { return name ? (db.get(`${userSelect} WHERE display_name = ? COLLATE NOCASE`, [String(name)]) || null) : null; }--function subjectFor(userId) { return db.subjectFor(userId); }++async function getUserById(id) { if (!id) return null; const u = await db.get(`${userSelect} WHERE id = ?`, [id]) || null; if (!u) ensureUsers([id]).catch(() => {}); return u; }++async function getUserByUsername(name) { return name ? (await db.get(`${userSelect} WHERE lower(username) = lower(?)`, [String(name)]) || null) : null; }++async function getUserByDisplayName(name) { return name ? (await db.get(`${userSelect} WHERE lower(display_name) = lower(?)`, [String(name)]) || null) : null; }++async function subjectFor(userId) { return await db.subjectFor(userId); }/** Fetch users the projection lacks. */async function ensureUsers(ids) {const want = [...new Set((ids || []).map(Number).filter((n) => Number.isInteger(n) && n > 0))];-- const missing = want.filter((id) => !db.get('SELECT 1 FROM ctx_users WHERE id = ?', [id]));++ const have = new Set((await db.all('SELECT id FROM ctx_users WHERE id = ANY(?)', [want])).map((r) => Number(r.id)));++ const missing = want.filter((id) => !have.has(id));if (!missing.length) return 0;const data = await read('/users/lookup', { ids: missing.slice(0, 500) });-- return upsertRows('ctx_users', USER_COLS, data.users || []);++ return await upsertRows('ctx_users', USER_COLS, data.users || []);}/** Look a user up by login (then display name) in the projection, asking Live on a miss. */async function ensureUserByUsername(name) {-- const local = getUserByUsername(name);++ const local = await getUserByUsername(name);if (local) return local;try {const data = await read('/users/lookup', { usernames: [String(name)] });-- upsertRows('ctx_users', USER_COLS, data.users || []);++ await upsertRows('ctx_users', USER_COLS, data.users || []);} catch (err) { console.warn('[LiveContext] user lookup:', err.message); }-- return getUserByUsername(name);++ return await getUserByUsername(name);}const STREAM_SELECT = `SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color,@@@ -273,20 +274,20 @@ const STREAM_SELECT = `SELECT s.*, u.username, u.display_name, u.avatar_url, u.pLEFT JOIN ctx_users u ON s.user_id = u.idLEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id`;--function getStreamById(id) {++async function getStreamById(id) {const sid = parseInt(id, 10);if (!sid) return null;-- const row = db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;++ const row = await db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;if (!row) ensureStream(sid).catch(() => {});return row;}const _streamFetch = new Swr(TTL.streamRow, async (sid) => {const data = await read(`/streams/${sid}`);if (data.stream) {-- upsertRows('ctx_streams', STREAM_COLS, [data.stream]);-- if (data.owner) upsertUser(data.owner);-- if (data.managed_stream) upsertRows('ctx_managed_streams', MS_COLS, [data.managed_stream]);-- if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);++ await upsertRows('ctx_streams', STREAM_COLS, [data.stream]);++ if (data.owner) await upsertUser(data.owner);++ if (data.managed_stream) await upsertRows('ctx_managed_streams', MS_COLS, [data.managed_stream]);++ if (data.channel) await upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);}return !!data.stream;});@@@ -294,8 +295,8 @@ const _streamFetch = new Swr(TTL.streamRow, async (sid) => {async function ensureStream(id) {const sid = parseInt(id, 10);if (!sid) return null;-- if (!db.get('SELECT 1 FROM ctx_streams WHERE id = ?', [sid])) await _streamFetch.ensure(sid);-- return db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;++ if (!await db.get('SELECT 1 FROM ctx_streams WHERE id = ?', [sid])) await _streamFetch.ensure(sid);++ return await db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;}/*** Read one stream from Live now, past the cache (a call hook or an ownership check wants its live@@@ -305,20 +306,20 @@ async function refreshStream(id) {const sid = parseInt(id, 10);if (!sid) return null;try { await _streamFetch.refresh(sid); } catch (err) { console.warn(`[LiveContext] stream ${sid}: ${err.message}`); }-- return db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;++ return await db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;}--function latestStreamIdForUser(userId) {++async function latestStreamIdForUser(userId) {if (!userId) return null;-- return db.get('SELECT id FROM ctx_streams WHERE user_id = ? ORDER BY id DESC LIMIT 1', [userId])?.id || null;++ return (await db.get('SELECT id FROM ctx_streams WHERE user_id = ? ORDER BY id DESC LIMIT 1', [userId]))?.id || null;}--function getLiveStreamsByUserId(userId) {-- return db.all(`${STREAM_SELECT} WHERE s.user_id = ? AND s.is_live = 1 ORDER BY s.started_at DESC`, [userId]);++async function getLiveStreamsByUserId(userId) {++ return await db.all(`${STREAM_SELECT} WHERE s.user_id = ? AND s.is_live = 1 ORDER BY s.started_at DESC`, [userId]);}--function getStreamsByUserId(userId, limit = 50) {-- return db.all(`${STREAM_SELECT} WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?`, [userId, limit]);++async function getStreamsByUserId(userId, limit = 50) {++ return await db.all(`${STREAM_SELECT} WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?`, [userId, limit]);}--function getManagedStreamsByUserId(userId) {-- return db.all(`++async function getManagedStreamsByUserId(userId) {++ return await db.all(`SELECT ms.*,(SELECT s.is_live FROM ctx_streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS is_currently_live,(SELECT s.id FROM ctx_streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS live_session_id@@@ -327,30 +328,30 @@ function getManagedStreamsByUserId(userId) {ORDER BY ms.sort_order ASC, ms.created_at ASC`, [userId]);}--function getChannelById(id) { return id ? (db.get('SELECT * FROM ctx_channels WHERE id = ?', [id]) || null) : null; }--function getChannelByUserId(userId) { return userId ? (db.get('SELECT * FROM ctx_channels WHERE user_id = ?', [userId]) || null) : null; }++async function getChannelById(id) { return id ? (await db.get('SELECT * FROM ctx_channels WHERE id = ?', [id]) || null) : null; }++async function getChannelByUserId(userId) { return userId ? (await db.get('SELECT * FROM ctx_channels WHERE user_id = ?', [userId]) || null) : null; }/** The channel row of a user, asking Live when the projection lacks it (null = the user has none). */async function ensureChannelForUser(userId) {-- const local = getChannelByUserId(userId);++ const local = await getChannelByUserId(userId);if (local || !userId) return local;try {const data = await read(`/channels/by-user/${parseInt(userId, 10)}`);-- if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);++ if (data.channel) await upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);} catch (err) { console.warn('[LiveContext] channel lookup:', err.message); }-- return getChannelByUserId(userId);++ return await getChannelByUserId(userId);}/** Live's ensureChannel(userId): create the channel if the user has none (effect). */async function createChannel(userId) {const data = await effect('ensure-channel', { user_id: userId });-- if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);-- return getChannelByUserId(userId);++ if (data.channel) await upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);++ return await getChannelByUserId(userId);}/** Write the channel's emote source switches (Live's channels.emote_sources) and refresh the cache. */async function setChannelEmoteSources(userId, sources) {const clean = emoteSources(sources);await effect('channel-emote-sources', { user_id: userId, sources: clean });-- db.run('UPDATE ctx_channels SET emote_sources = ? WHERE user_id = ?', [JSON.stringify(clean), Number(userId)]);++ await db.run('UPDATE ctx_channels SET emote_sources = ? WHERE user_id = ?', [JSON.stringify(clean), Number(userId)]);return clean;}@@@ -371,38 +372,38 @@ function _emitSettings(channelId, settings) {// moderation settings and moderator ids are Chat's own rows now, read in place.const _policy = new Swr(TTL.policy, async (channelId) => {const data = await read(`/channels/${channelId}/policy`);-- if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);++ if (data.channel) await upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);// The moderation settings are Chat's own rows: a fresh channel read refreshes the listeners'// baseline (the chat server records what the room was last told; the first read announces nothing).-- _emitSettings(channelId, getChannelModerationSettings(channelId));++ _emitSettings(channelId, await getChannelModerationSettings(channelId));return { language: data.language || 'en' };});function _policyFor(channelId) { return channelId ? _policy.peek(Number(channelId)) : undefined; }// Chat is the only writer of these tables (C-04 done): an indexed local row, no network, no cache.--function getChannelModerationSettings(channelId) {-- return (channelId && db.get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)])) || defaultModerationSettings(channelId);++async function getChannelModerationSettings(channelId) {++ return (channelId && await db.get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)])) || defaultModerationSettings(channelId);}--function isChannelModerator(userId, channelId) {-- return !!(userId && channelId && db.get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [Number(userId), Number(channelId)]));++async function isChannelModerator(userId, channelId) {++ return !!(userId && channelId && await db.get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [Number(userId), Number(channelId)]));}--function channelLanguage(channelUserId) {-- const ch = getChannelByUserId(channelUserId);++async function channelLanguage(channelUserId) {++ const ch = await getChannelByUserId(channelUserId);if (!ch) return 'en';const p = _policyFor(ch.id);return (p && p.language) || 'en';}--function getChannelAlertSoundsByUser(userId) {-- const ch = getChannelByUserId(userId);++async function getChannelAlertSoundsByUser(userId) {++ const ch = await getChannelByUserId(userId);if (!ch) return {};-- const s = getChannelModerationSettings(ch.id);++ const s = await getChannelModerationSettings(ch.id);const pick = { donation_sound_url: s.donation_sound_url || null, donation_sound_mime: s.donation_sound_mime || null, goal_sound_url: s.goal_sound_url || null, goal_sound_mime: s.goal_sound_mime || null };return pick;}function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(channelId)) : Promise.resolve(); }--function invalidateChannel(channelId) {++async function invalidateChannel(channelId) {if (!channelId) return;_policy.invalidate(Number(channelId));-- _emitSettings(channelId, getChannelModerationSettings(channelId));++ _emitSettings(channelId, await getChannelModerationSettings(channelId));}/*** A fresh policy read that starts after the call (Chat just had Live write a setting): a load already@@@ -421,7 +422,7 @@ let _bans = { rows: [], version: null, at: 0, cidr: [] };let _bansLoading = null;// A ban ends at expires_at, read as a UTC instant: /timeout stores an ISO string ('…T…Z'), other--// writers SQLite's 'YYYY-MM-DD HH:MM:SS'. Live compares the TEXT with CURRENT_TIMESTAMP, where++// writers SQLite's 'YYYY-MM-DD HH:MM:SS'. Live compares the TEXT with ov_now(), where// 'T' sorts after ' ', so a 60-second timeout lasted until the end of that UTC day; Chat, which// enforces chat bans, ends it on time (parity script, roadmap WS-I task 6). A value that is not a// date keeps Live's TEXT comparison.@@@ -475,9 +476,9 @@ async function refreshBans() {function _bansFresh() { if (Date.now() - _bans.at > TTL.bans) refreshBans().catch(() => {}); return _bans; }// A fresh read that starts after the call: a load already in flight may have left before a ban// was written, so it is waited for and followed by another.--function invalidateBans() {-- const reload = () => { _bans.version = null; _bans.at = 0; return refreshBans(); };-- return _bansLoading ? _bansLoading.then(reload) : reload();++async function invalidateBans() {++ const reload = async () => { _bans.version = null; _bans.at = 0; return await refreshBans(); };++ return _bansLoading ? _bansLoading.then(reload) : await reload();}function isUserBanned(userId, streamId) {@@@ -676,9 +677,9 @@ async function authenticate(token) {const hit = _auth.get(key);if (hit && hit.until > now) return hit.user ? { ...hit.user } : null;if (_authPending.has(key)) return _authPending.get(key).then((u) => (u ? { ...u } : null));-- const p = read('/auth', { token }).then((data) => {++ const p = read('/auth', { token }).then(async (data) => {const user = data && data.user ? data.user : null;-- if (user) upsertUser(user);++ if (user) await upsertUser(user);const exp = data && data.expires_at ? Date.parse(data.expires_at) : null;const ttl = user ? TTL.auth : TTL.authNegative;const until = Math.min(now + ttl, exp && Number.isFinite(exp) ? exp : Infinity);@@@ -722,18 +723,18 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {const stream = streamId ? await ensureStream(streamId) : null;const ownerId = (stream && stream.user_id) || channelUserId || null;const tasks = [ensureSettings()];-- let channel = stream && stream.channel_id ? getChannelById(stream.channel_id) : null;++ let channel = stream && stream.channel_id ? await getChannelById(stream.channel_id) : null;if (!channel && ownerId) channel = await ensureChannelForUser(ownerId);if (channel) tasks.push(ensurePolicy(channel.id));// Live's chat server asks canModerateChannel(user, channelUserId) — the channel OWNER's user// id where a channel id belongs — for the AI-viewer / PowerChat "is mod" flag. Kept as it// was; that lookup is warmed too so a message never waits on it.if (ownerId && (!channel || channel.id !== ownerId)) tasks.push(ensurePolicy(ownerId));-- if (user && user.id) { tasks.push(_follows.ensure(Number(user.id))); tasks.push(ensureDecor([user.id])); if (ip) tasks.push(ensureAnonFirstSeen(ip)); }++ if (user && user.id) { tasks.push(_follows.ensure(Number(user.id))); tasks.push(await ensureDecor([user.id])); if (ip) tasks.push(ensureAnonFirstSeen(ip)); }await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));-- if (channel && getChannelModerationSettings(channel.id).ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});++ if (channel && (await getChannelModerationSettings(channel.id)).ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});// Sub-only room: the viewer's subscription answer, so their first line does not wait for it.-- if (channel && user && user.id && ownerId && Number(ownerId) !== Number(user.id) && getChannelModerationSettings(channel.id).sub_only) {++ if (channel && user && user.id && ownerId && Number(ownerId) !== Number(user.id) && (await getChannelModerationSettings(channel.id)).sub_only) {await ensureSubscriber(user.id, ownerId).catch(() => {});}})();@@@ -743,12 +744,12 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {// ── Projection sync ─────────────────────────────────────────────────────────────────────────async function syncTable(kind, table, cols, { full = false } = {}) {-- let after = full ? 0 : (db.get(`SELECT COALESCE(MAX(id), 0) AS m FROM ${table}`)?.m || 0);++ let after = full ? 0 : ((await db.get(`SELECT COALESCE(MAX(id), 0) AS m FROM ${table}`))?.m || 0);let total = 0;for (let pages = 0; pages < 10000; pages++) {const data = await read(`/${kind}?after_id=${after}&limit=${PAGE}`);const rows = data.rows || [];-- total += upsertRows(table, cols, rows);++ total += await upsertRows(table, cols, rows);if (rows.length < PAGE) break;after = rows[rows.length - 1].id;}@@@ -756,24 +757,24 @@ async function syncTable(kind, table, cols, { full = false } = {}) {}async function syncActiveStreams() {const data = await read('/streams/active');-- upsertRows('ctx_streams', STREAM_COLS, data.rows || []);++ await upsertRows('ctx_streams', STREAM_COLS, data.rows || []);// Streams that were live here but are not in Live's active list any more ended between// polls (and were not "recently ended" either): mark them offline.const liveNow = new Set((data.rows || []).filter((r) => r.is_live).map((r) => Number(r.id)));-- for (const r of db.all('SELECT id FROM ctx_streams WHERE is_live = 1')) {-- if (!liveNow.has(Number(r.id))) db.run('UPDATE ctx_streams SET is_live = 0 WHERE id = ?', [r.id]);++ for (const r of await db.all('SELECT id FROM ctx_streams WHERE is_live = 1')) {++ if (!liveNow.has(Number(r.id))) await db.run('UPDATE ctx_streams SET is_live = 0 WHERE id = ?', [r.id]);}}const SCHEDULE = [// [name, every ms, fn]-- ['users+', 30_000, () => syncTable('users', 'ctx_users', USER_COLS)],-- ['users*', 15 * 60_000, () => syncTable('users', 'ctx_users', USER_COLS, { full: true })],-- ['streams+', 10_000, () => syncTable('streams', 'ctx_streams', STREAM_COLS)],++ ['users+', 30_000, async () => await syncTable('users', 'ctx_users', USER_COLS)],++ ['users*', 15 * 60_000, async () => await syncTable('users', 'ctx_users', USER_COLS, { full: true })],++ ['streams+', 10_000, async () => await syncTable('streams', 'ctx_streams', STREAM_COLS)],['streams~', 10_000, syncActiveStreams],-- ['streams*', 30 * 60_000, () => syncTable('streams', 'ctx_streams', STREAM_COLS, { full: true })],-- ['managed*', 5 * 60_000, () => syncTable('managed-streams', 'ctx_managed_streams', MS_COLS, { full: true })],-- ['channels*', 5 * 60_000, () => syncTable('channels', 'ctx_channels', CHANNEL_COLS, { full: true })],++ ['streams*', 30 * 60_000, async () => await syncTable('streams', 'ctx_streams', STREAM_COLS, { full: true })],++ ['managed*', 5 * 60_000, async () => await syncTable('managed-streams', 'ctx_managed_streams', MS_COLS, { full: true })],++ ['channels*', 5 * 60_000, async () => await syncTable('channels', 'ctx_channels', CHANNEL_COLS, { full: true })],// refreshBans and ensureSettings keep the cached value on a Live error instead of throwing// (callers want the stale value); for the schedule a refresh that did not land is a failure.['bans', TTL.bans, async () => { const t0 = Date.now(); await refreshBans(); if (_bans.at < t0) throw new Error('bans not refreshed'); }],@@@ -882,7 +883,7 @@ const effects = {logIp(entry) { if (entry && entry.ip && entry.ip !== 'unknown') { _ipLog.push(entry); if (_ipLog.length > 5000) _ipLog.splice(0, _ipLog.length - 5000); } },viewerCount(streamId, count) { if (streamId) _viewerCounts.set(String(streamId), count); },viewerSnapshot(streamId, count, chatActivity) { _snapshots.push({ stream_id: streamId, viewer_count: count, chat_messages_5m: chatActivity }); },-- setUserColor: (userId, color) => effect('user-color', { user_id: userId, color }).then((r) => { db.run('UPDATE ctx_users SET profile_color = ? WHERE id = ?', [color, userId]); return r; }),++ setUserColor: (userId, color) => effect('user-color', { user_id: userId, color }).then(async (r) => { await db.run('UPDATE ctx_users SET profile_color = ? WHERE id = ?', [color, userId]); return r; }),// The ban is in Chat's cache before the moderator is answered (and before their next check).ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),// /slow and alert sounds: Chat writes channel_moderation_settings itself (C-04 done; the callers@@@ -892,13 +893,13 @@ const effects = {if (fields && fields.slow_mode_seconds !== undefined) f.slow_mode_seconds = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);if (fields && fields.sub_only !== undefined) f.sub_only = fields.sub_only ? 1 : 0;if (!channelId || !Object.keys(f).length) return Promise.reject(new LiveError(400, 'no chat-settable fields'));-- return Promise.resolve().then(() => {-- db.upsertChannelModerationSettings(Number(channelId), f);++ return Promise.resolve().then(async () => {++ await db.upsertChannelModerationSettings(Number(channelId), f);return { ok: true };});},setChannelAlertSound: (channelId, kind, url, mime, actorUserId) => {-- return Promise.resolve().then(() => {++ return Promise.resolve().then(async () => {const file = url ? require('path').resolve(String(url)) : null;if (file) {// Live's rule: alert sounds are files in the shared sounds directory.@@@ -907,16 +908,16 @@ const effects = {try { inside = require('path').dirname(fs.realpathSync(file)) === fs.realpathSync(require('path').resolve(config.sounds.path)); } catch { inside = false; }if (!inside) throw new LiveError(400, 'alert sounds live in the sounds directory');}-- db.setChannelAlertSound(Number(channelId), kind === 'goal' ? 'goal' : 'donation', file, file ? String(mime || 'audio/mpeg') : null);++ await db.setChannelAlertSound(Number(channelId), kind === 'goal' ? 'goal' : 'donation', file, file ? String(mime || 'audio/mpeg') : null);return { ok: true };});},// One call per real chat line: coins chat bonus, AI viewers, PowerChat relay (Live decides each).-- chatMessage: (body) => effect('chat-message', body),++ chatMessage: async (body) => await effect('chat-message', body),aiModCommand: (channelUserId, streamId, args, opts) => effect('ai/mod-command', { channel_user_id: channelUserId, stream_id: streamId, args, by: opts && opts.by }).then((r) => r.reply),arenaCommand: (client, cmd, parts) => fire('arena-command', { client: clientHandle(client), cmd, parts }),-- mediaQueue: (op, args) => effect('media-queue', { op, ...args }),-- hardwareCommand: (streamerUserId, command, fromUser) => effect('hardware', { streamer_user_id: streamerUserId, command, from_user: fromUser }),++ mediaQueue: async (op, args) => await effect('media-queue', { op, ...args }),++ hardwareCommand: async (streamerUserId, command, fromUser) => await effect('hardware', { streamer_user_id: streamerUserId, command, from_user: fromUser }),createPaste: (body) => effect('paste', body).then((r) => r.paste),translate: (text, channelUserId) => effect('translate', { text, channel_user_id: channelUserId }).then((r) => r.translation || null),notifyDm: (body) => fire('notify/dm', body),@@@ -925,7 +926,7 @@ const effects = {notifyCallInvite: (body) => fire('notify/call-invite', body),// Awaitable (a sound's Media copy is removed from Live's row before the row goes), never throws.assetSync: (op, assetId) => effect('asset-sync', { op, asset_id: assetId || null }).catch((err) => console.warn(`[LiveContext] asset-sync: ${err.message}`)),-- userProfile: (username, viewerId) => read(`/users/profile?username=${encodeURIComponent(username)}${viewerId ? `&viewer_id=${viewerId}` : ''}`),++ userProfile: async (username, viewerId) => await read(`/users/profile?username=${encodeURIComponent(username)}${viewerId ? `&viewer_id=${viewerId}` : ''}`),};module.exports = {@@@ -79,66 +79,11 @@ class RoomError extends Error {}const fail = (status, code, message) => { throw new RoomError(status, code, message); };--let ready = false;--function ensureSchema() {-- if (ready) return;-- const d = db.getDb();-- d.exec(`-- CREATE TABLE IF NOT EXISTS rooms (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- slug TEXT UNIQUE NOT NULL,-- name TEXT NOT NULL,-- topic TEXT,-- kind TEXT NOT NULL DEFAULT 'community',-- visibility TEXT NOT NULL DEFAULT 'public',-- owner_id INTEGER NOT NULL,-- owner_subject TEXT,-- slow_seconds INTEGER NOT NULL DEFAULT 0,-- message_count INTEGER NOT NULL DEFAULT 0,-- last_message_at DATETIME,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- archived_at DATETIME-- );-- CREATE TABLE IF NOT EXISTS room_members (-- room_id INTEGER NOT NULL,-- user_id INTEGER NOT NULL,-- role TEXT NOT NULL DEFAULT 'member',-- last_read_id INTEGER NOT NULL DEFAULT 0,-- joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (room_id, user_id)-- );-- CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);-- CREATE TABLE IF NOT EXISTS room_messages (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- room_id INTEGER NOT NULL,-- user_id INTEGER NOT NULL,-- subject_id TEXT,-- message TEXT NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- is_deleted INTEGER NOT NULL DEFAULT 0,-- deleted_by INTEGER-- );-- CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_id, id);-- CREATE TABLE IF NOT EXISTS room_attachments (-- room_id INTEGER NOT NULL,-- service TEXT NOT NULL,-- resource TEXT NOT NULL,-- title TEXT,-- attached_by INTEGER NOT NULL,-- attached_by_subject TEXT,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (room_id, service, resource)-- );-- `);-- // Additive columns on tables the first release created (ADD COLUMN is a metadata change: safe live).-- const cols = (t) => new Set(d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name));-- if (!cols('rooms').has('join_role')) d.exec('ALTER TABLE rooms ADD COLUMN join_role TEXT');-- if (!cols('room_members').has('last_seen_at')) d.exec('ALTER TABLE room_members ADD COLUMN last_seen_at DATETIME');-- ready = true;--}++// The rooms tables are in migrations/0001_initial.sql (PostgreSQL); nothing is created at runtime.++async function ensureSchema() {}const isStaff = (user) => !!user && permissions.can(user, 'staff.moderation.chat');--const roleOf = (roomId, userId) => (userId ? (db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);++const roleOf = async (roomId, userId) => (userId ? (await db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);const siteBanned = (user) => !!user && (user.is_banned || (() => { try { return ctx.isUserBanned(user.id, null); } catch { return false; } })());const kindOf = (room) => (KINDS.includes(room && room.kind) ? room.kind : 'community');const joinRoleOf = (room) => (kindOf(room) === 'call' && JOIN_ROLES_CALL.includes(room.join_role) ? room.join_role : JOIN_ROLE[kindOf(room)]);@@@ -147,29 +92,29 @@ function slugify(name) {return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');}--function publicRoom(r, extra = {}) {++async function publicRoom(r, extra = {}) {if (!r) return null;return {id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: kindOf(r), visibility: r.visibility,...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,-- created_at: r.created_at, members: (db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,++ created_at: r.created_at, members: (await db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,...extra,};}--function bySlug(slug) {-- ensureSchema();-- return db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;++async function bySlug(slug) {++ await ensureSchema();++ return await db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;}/*** What `user` may do in `room`: { role, read, post, join, talk, moderate, manage } — the matrix above.* `join` and `talk` are the room's call (call rooms only).*/--function access(room, user) {++async function access(room, user) {const kind = kindOf(room);-- const role = user ? roleOf(room.id, user.id) : null;++ const role = user ? await roleOf(room.id, user.id) : null;const staff = isStaff(user);const blocked = role === 'blocked';const member = !!role && !blocked;@@@ -182,8 +127,8 @@ function access(room, user) {return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };}--function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {-- ensureSchema();++async function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {++ await ensureSchema();if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');const n = String(name || '').replace(/\s+/g, ' ').trim();@@@ -195,59 +140,59 @@ function create(user, { name, slug, topic, visibility = 'public', kind = 'communif (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');-- const owned = db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id]).n;++ const owned = (await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id])).n;if (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);-- if (bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');++ if (await bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;-- const id = db.transaction(() => {-- const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',++ const id = await db.tx(async () => {++ const r = await db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',[s, n, t, kind, visibility, user.id, user.subject_id || null, kind === 'call' ? (join_role || JOIN_ROLE.call) : null]);-- db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', CURRENT_TIMESTAMP)", [r.lastInsertRowid, user.id]);++ await db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', ov_now())", [r.lastInsertRowid, user.id]);return Number(r.lastInsertRowid);});-- return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });++ return await publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });}/** Public rooms by recent activity, plus the rooms `user` belongs to with unread counts. */--function list(user, { limit = 50 } = {}) {-- ensureSchema();++async function list(user, { limit = 50 } = {}) {++ await ensureSchema();const lim = Math.min(100, Math.max(1, Number(limit) || 50));-- const pub = db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim]).map((r) => publicRoom(r));-- const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,++ const pub = (await Promise.all((await db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim])).map(async (r) => await publicRoom(r))));++ const mine = user ? (await Promise.all((await db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,(SELECT COUNT(*) FROM room_messages x WHERE x.room_id = r.id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != ?) AS unreadFROM room_members m JOIN rooms r ON r.id = m.room_idWHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL-- ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })) : [];++ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id])).map(async (r) => await publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })))) : [];return { public: pub, mine };}/** Unread messages across the rooms `user` belongs to (the site's navigation shows it). */--function unreadTotal(user) {++async function unreadTotal(user) {if (!user) return 0;-- ensureSchema();-- return (db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL++ await ensureSchema();++ return (await db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULLJOIN room_messages x ON x.room_id = m.room_id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != m.user_idWHERE m.user_id = ? AND m.role != 'blocked'`, [user.id]) || {}).n || 0;}/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */--function join(room, user) {++async function join(room, user) {if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');-- const role = roleOf(room.id, user.id);++ const role = await roleOf(room.id, user.id);if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');if (role) return role;// A private room is not there for someone it has not invited (the same answer as a missing room).if (room.visibility !== 'public' && !isStaff(user)) fail(404, 'rooms.not_found', 'No such room');-- const last = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;++ const last = (await db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;const r = joinRoleOf(room);-- db.run('INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)', [room.id, user.id, r, last]);++ await db.run('INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, ?, ?, ov_now())', [room.id, user.id, r, last]);return r;}--function leave(room, user) {-- const role = roleOf(room.id, user && user.id);++async function leave(room, user) {++ const role = await roleOf(room.id, user && user.id);if (role === 'owner') fail(409, 'rooms.owner_leaves', 'The owner cannot leave their room');-- if (role && role !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);++ if (role && role !== 'blocked') await db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);}const _rate = new Map();@@@ -266,9 +211,9 @@ function rateOk(key, now = Date.now()) {// message, or turning the room private, announces chat.room.message.deleted with a redaction, so what// the room had published stops being replayable. Both are written in the transaction of the change.const REDACT_CHUNK = 500;--function announceMessage(room, user, id, message, now) {++async function announceMessage(room, user, id, message, now) {if (room.visibility !== 'public') return;-- require('../events/outbox').enqueue({++ await require('../events/outbox').enqueue({event_type: 'chat.room.message.created',visibility: 'public',actorSubject: user.subject_id || null,@@@ -284,11 +229,11 @@ function announceMessage(room, user, id, message, now) {},});}--function announceDeleted(room, ids) {++async function announceDeleted(room, ids) {const list = [...new Set(ids.map(Number).filter((n) => Number.isInteger(n) && n > 0))];for (let i = 0; i < list.length; i += REDACT_CHUNK) {const part = list.slice(i, i + REDACT_CHUNK);-- require('../events/outbox').enqueue({++ await require('../events/outbox').enqueue({event_type: 'chat.room.message.deleted',visibility: 'public',subject: { type: 'chat_room_message', id: String(part[0]) },@@@ -297,8 +242,8 @@ function announceDeleted(room, ids) {}}--function post(room, user, text, { now = Date.now() } = {}) {-- const a = access(room, user);++async function post(room, user, text, { now = Date.now() } = {}) {++ const a = await access(room, user);if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');if (!a.post) {@@@ -312,53 +257,53 @@ function post(room, user, text, { now = Date.now() } = {}) {if (wordFilter.isSpam(body)) fail(422, 'rooms.spam', 'That looks like spam');if (!rateOk(`${room.id}:${user.id}`, now)) fail(429, 'rooms.slow_down', 'Slow down a little');if (room.slow_seconds > 0 && !a.moderate) {-- const last = db.get('SELECT created_at FROM room_messages WHERE room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1', [room.id, user.id]);++ const last = await db.get('SELECT created_at FROM room_messages WHERE room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1', [room.id, user.id]);if (last && now - Date.parse(`${String(last.created_at).replace(' ', 'T')}Z`) < room.slow_seconds * 1000) fail(429, 'rooms.slow_mode', `Slow mode: one message every ${room.slow_seconds} seconds`);}const filtered = wordFilter.check(body);const message = filtered.safe ? body : filtered.filtered;-- const id = db.transaction(() => {-- const r = db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);-- db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?', [room.id]);-- db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);-- announceMessage(room, user, Number(r.lastInsertRowid), message, now);++ const id = await db.tx(async () => {++ const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);++ await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);++ await db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);++ await announceMessage(room, user, Number(r.lastInsertRowid), message, now);return Number(r.lastInsertRowid);});-- return messageById(id);++ return await messageById(id);}const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, u.username, u.display_name, u.profile_color, u.role AS user_roleFROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id`;--function messageById(id) { return db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null; }++async function messageById(id) { return await db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null; }/** Messages oldest → newest: the newest page, one before `before`, or everything after `after`. */--function history(room, { before = null, after = null, limit = 60 } = {}) {++async function history(room, { before = null, after = null, limit = 60 } = {}) {const lim = Math.min(200, Math.max(1, Number(limit) || 60));-- if (after != null) return db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?`, [room.id, Number(after) || 0, lim]);++ if (after != null) return await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?`, [room.id, Number(after) || 0, lim]);const rows = before != null-- ? db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])-- : db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);++ ? await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])++ : await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);return rows.reverse();}--function markRead(room, user, lastId) {++async function markRead(room, user, lastId) {if (!user) return;-- const top = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;++ const top = (await db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;const to = Math.min(top, Number(lastId) || top);-- db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?), last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);++ await db.run('UPDATE room_members SET last_read_id = GREATEST(last_read_id, ?), last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);}--function deleteMessage(room, user, messageId) {-- const m = db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);++async function deleteMessage(room, user, messageId) {++ const m = await db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);if (!m || m.is_deleted) fail(404, 'rooms.no_message', 'No such message');const own = user && m.user_id === user.id;-- const a = access(room, user);++ const a = await access(room, user);if (!own && !a.moderate) fail(403, 'rooms.not_yours', 'Only the author or a room moderator can delete this');-- db.transaction(() => {-- db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);-- if (room.visibility === 'public') announceDeleted(room, [m.id]);++ await db.tx(async () => {++ await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);++ if (room.visibility === 'public') await announceDeleted(room, [m.id]);});-- if (!own) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });++ if (!own) await db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });return m.id;}@@@ -367,34 +312,34 @@ function deleteMessage(room, user, messageId) {* remove them ('none'). The role must be one of the room kind's (KIND_ROLES); mods are the owner's (or* chat staff's) to appoint and remove; everything else is any moderator's.*/--function setRole(room, actor, targetUserId, role) {++async function setRole(room, actor, targetUserId, role) {const kind = kindOf(room);if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');if (role !== 'none' && !KIND_ROLES[kind].includes(role)) fail(422, 'rooms.role_kind', `A ${kind} room has no ${role} role (its roles: ${KIND_ROLES[kind].filter((r) => r !== 'owner').join(', ')})`);-- const a = access(room, actor);++ const a = await access(room, actor);if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');-- const current = roleOf(room.id, targetUserId);++ const current = await roleOf(room.id, targetUserId);if (current === 'owner') fail(403, 'rooms.owner', 'The owner\'s role does not change');if (role === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner appoints moderators');if (current === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner changes a moderator');-- const target = ctx.getUserById(targetUserId);++ const target = await ctx.getUserById(targetUserId);if (!target) fail(404, 'rooms.no_user', 'No such person');-- if (role === 'none') { if (current && current !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }-- else if (current) db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);-- else db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);++ if (role === 'none') { if (current && current !== 'blocked') await db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }++ else if (current) await db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);++ else await db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);const action = role === 'blocked' && current !== 'blocked' ? 'room_block': current === 'blocked' && role !== 'blocked' ? 'room_unblock': role === 'mod' && current !== 'mod' ? 'room_mod_add': current === 'mod' && role !== 'mod' ? 'room_mod_remove': role === 'speaker' && current !== 'speaker' ? 'room_speaker_add': current === 'speaker' && role !== 'speaker' ? 'room_speaker_remove' : null;-- if (action) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: action, details: { from: current, to: role } });++ if (action) await db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: action, details: { from: current, to: role } });return role;}--function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {-- if (!access(room, actor).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');++async function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {++ if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');const next = { ...room };if (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < 3 || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', 'Room names are 3 to 40 characters'); next.name = n; }if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;@@@ -404,21 +349,21 @@ function update(room, actor, { name, topic, visibility, slow_seconds, join_roleif (kindOf(room) !== 'call' || !JOIN_ROLES_CALL.includes(join_role)) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');next.join_role = join_role;}-- db.transaction(() => {-- db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, room.id]);++ await db.tx(async () => {++ await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, room.id]);// Public → private: everything the room published stops being replayable on the realtime plane.if (room.visibility === 'public' && next.visibility === 'private') {-- announceDeleted(room, db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id]).map((r) => r.id));++ await announceDeleted(room, (await db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id])).map((r) => r.id));}});-- return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));++ return await publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));}/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */--function members(room, { seen = false } = {}) {++async function members(room, { seen = false } = {}) {const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;-- return db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id-- WHERE m.room_id = ? ORDER BY ${order}, u.username COLLATE NOCASE LIMIT 500`, [room.id]);++ return await db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id++ WHERE m.room_id = ? ORDER BY ${order}, lower(u.username) LIMIT 500`, [room.id]);}// ── Attachments: the room's side of a link from another service (a Community space) ──@@@ -431,43 +376,43 @@ function attachmentShape(r) {}/** Attach the room to { service, resource, title? }. Idempotent: → { attachment, created }. Managers only. */--function attach(room, user, { service, resource, title } = {}) {-- ensureSchema();++async function attach(room, user, { service, resource, title } = {}) {++ await ensureSchema();if (!user) fail(401, 'rooms.sign_in', 'Sign in first');-- if (!access(room, user).manage) fail(403, 'rooms.not_owner', 'Only the room\'s owner attaches it elsewhere');++ if (!(await access(room, user)).manage) fail(403, 'rooms.not_owner', 'Only the room\'s owner attaches it elsewhere');const svc = ATTACH_SERVICES[service];if (!svc) fail(422, 'rooms.attach_service', `A room attaches to: ${Object.keys(ATTACH_SERVICES).join(', ')}`);const res = String(resource || '').toLowerCase();if (!svc.resource.test(res)) fail(422, 'rooms.attach_resource', `That is not a ${service} ${svc.noun}`);const t = title == null ? null : String(title).replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) || null;-- const existing = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res]);++ const existing = await db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res]);if (existing) {-- if (t && t !== existing.title) db.run('UPDATE room_attachments SET title = ? WHERE room_id = ? AND service = ? AND resource = ?', [t, room.id, service, res]);++ if (t && t !== existing.title) await db.run('UPDATE room_attachments SET title = ? WHERE room_id = ? AND service = ? AND resource = ?', [t, room.id, service, res]);return { attachment: attachmentShape({ ...existing, title: t || existing.title }), created: false };}-- db.run('INSERT INTO room_attachments (room_id, service, resource, title, attached_by, attached_by_subject) VALUES (?, ?, ?, ?, ?, ?)', [room.id, service, res, t, user.id, user.subject_id || null]);-- return { attachment: attachmentShape(db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res])), created: true };++ await db.run('INSERT INTO room_attachments (room_id, service, resource, title, attached_by, attached_by_subject) VALUES (?, ?, ?, ?, ?, ?)', [room.id, service, res, t, user.id, user.subject_id || null]);++ return { attachment: attachmentShape(await db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res])), created: true };}/** Remove an attachment: the room's managers, or the person who made it. Idempotent: → { removed }. */--function detach(room, user, service, resource) {-- ensureSchema();++async function detach(room, user, service, resource) {++ await ensureSchema();if (!user) fail(401, 'rooms.sign_in', 'Sign in first');-- const row = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, String(service || ''), String(resource || '').toLowerCase()]);++ const row = await db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, String(service || ''), String(resource || '').toLowerCase()]);// Refused before "not attached": otherwise a member could tell which attachments exist (only the// owner may list them) from 403 versus { removed: false }.-- if (!access(room, user).manage && !(row && row.attached_by === user.id)) fail(403, 'rooms.not_owner', 'Only the room\'s owner, or whoever attached it, detaches it');++ if (!(await access(room, user)).manage && !(row && row.attached_by === user.id)) fail(403, 'rooms.not_owner', 'Only the room\'s owner, or whoever attached it, detaches it');if (!row) return { removed: false };-- db.run('DELETE FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, row.service, row.resource]);++ await db.run('DELETE FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, row.service, row.resource]);return { removed: true };}--function attachments(room) {-- ensureSchema();-- return db.all('SELECT * FROM room_attachments WHERE room_id = ? ORDER BY created_at, service, resource', [room.id]).map(attachmentShape);++async function attachments(room) {++ await ensureSchema();++ return (await db.all('SELECT * FROM room_attachments WHERE room_id = ? ORDER BY created_at, service, resource', [room.id])).map(attachmentShape);}--function _reset() { ready = false; _rate.clear(); }++function _reset() { _rate.clear(); }module.exports = {ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,@@@ -54,10 +54,10 @@ const chatServer = () => require('../chat/chat-server');* After a role or room change: sockets following the room learn what they may do now, and a running* call in it follows the room's roles (no-op when calls are off or nobody is in).*/--function applyRoomChange(room) {-- const fresh = rooms.bySlug(room.slug) || room;-- try { chatServer().refreshRoomAccess(fresh); } catch (err) { console.warn('[Rooms] socket update:', err.message); }-- try { require('../calls/call-server').applyRoomAccess(fresh); } catch (err) { console.warn('[Rooms] call update:', err.message); }++async function applyRoomChange(room) {++ const fresh = await rooms.bySlug(room.slug) || room;++ try { await chatServer().refreshRoomAccess(fresh); } catch (err) { console.warn('[Rooms] socket update:', err.message); }++ try { await require('../calls/call-server').applyRoomAccess(fresh); } catch (err) { console.warn('[Rooms] call update:', err.message); }}/** The room's call as the room page shows it: on or off, and who is in. */function callOf(room) {@@@ -71,120 +71,120 @@ function callOf(room) {const personOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Attach rooms with your own sign-in, not an API token', code: 'rooms.person_only' }) : next());/** The room behind :slug when the caller may read it (else a 404 that says nothing about private rooms). */--function readable(req, res) {-- const room = rooms.bySlug(req.params.slug);-- if (!room || !rooms.access(room, req.user || null).read) { res.status(404).json({ error: 'No such room', code: 'rooms.not_found' }); return null; }++async function readable(req, res) {++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, req.user || null)).read) { res.status(404).json({ error: 'No such room', code: 'rooms.not_found' }); return null; }return room;}--router.get('/', optionalAuth, (req, res) => {-- try { res.set('Cache-Control', 'private, no-store').json(rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }++router.get('/', optionalAuth, async (req, res) => {++ try { res.set('Cache-Control', 'private, no-store').json(await rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }});--router.post('/', requireAuth, createLimit, (req, res) => {-- try { res.status(201).json({ room: rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }++router.post('/', requireAuth, createLimit, async (req, res) => {++ try { res.status(201).json({ room: await rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }});--router.get('/:slug', optionalAuth, (req, res) => {-- const room = readable(req, res); if (!room) return;-- const a = rooms.access(room, req.user || null);++router.get('/:slug', optionalAuth, async (req, res) => {++ const room = await readable(req, res); if (!room) return;++ const a = await rooms.access(room, req.user || null);res.set('Cache-Control', 'private, no-store').json({-- room: rooms.publicRoom(room, { role: a.role }),++ room: await rooms.publicRoom(room, { role: a.role }),can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },...(room.kind === 'call' ? { call: callOf(room) } : {}),});});--router.patch('/:slug', requireAuth, manageLimit, (req, res) => {-- const room = readable(req, res); if (!room) return;-- try { const out = rooms.update(room, req.user, req.body || {}); applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }++router.patch('/:slug', requireAuth, manageLimit, async (req, res) => {++ const room = await readable(req, res); if (!room) return;++ try { const out = await rooms.update(room, req.user, req.body || {}); await applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }});--router.get('/:slug/messages', optionalAuth, (req, res) => {-- const room = readable(req, res); if (!room) return;++router.get('/:slug/messages', optionalAuth, async (req, res) => {++ const room = await readable(req, res); if (!room) return;try {-- const messages = rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });++ const messages = await rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });res.set('Cache-Control', 'private, no-store').json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });} catch (err) { send(res, err); }});--router.post('/:slug/messages', requireAuth, postLimit, (req, res) => {-- const room = readable(req, res); if (!room) return;++router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {++ const room = await readable(req, res); if (!room) return;try {-- const message = rooms.post(room, req.user, req.body && req.body.message);++ const message = await rooms.post(room, req.user, req.body && req.body.message);chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });res.status(201).json({ message });} catch (err) { send(res, err); }});--router.delete('/:slug/messages/:id', requireAuth, deleteLimit, (req, res) => {-- const room = readable(req, res); if (!room) return;++router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res) => {++ const room = await readable(req, res); if (!room) return;try {-- const id = rooms.deleteMessage(room, req.user, parseInt(req.params.id, 10));++ const id = await rooms.deleteMessage(room, req.user, parseInt(req.params.id, 10));chatServer().broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });res.json({ ok: true, id });} catch (err) { send(res, err); }});--router.post('/:slug/join', requireAuth, manageLimit, (req, res) => {-- const room = rooms.bySlug(req.params.slug);++router.post('/:slug/join', requireAuth, manageLimit, async (req, res) => {++ const room = await rooms.bySlug(req.params.slug);if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });-- try { const role = rooms.join(room, req.user); applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }++ try { const role = await rooms.join(room, req.user); await applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }});--router.post('/:slug/leave', requireAuth, manageLimit, (req, res) => {-- const room = readable(req, res); if (!room) return;++router.post('/:slug/leave', requireAuth, manageLimit, async (req, res) => {++ const room = await readable(req, res); if (!room) return;try {-- rooms.leave(room, req.user);++ await rooms.leave(room, req.user);chatServer().removeFromRoom(room.id, req.user.id);-- applyRoomChange(room);++ await applyRoomChange(room);res.json({ ok: true });} catch (err) { send(res, err); }});--router.post('/:slug/read', requireAuth, readLimit, (req, res) => {-- const room = readable(req, res); if (!room) return;-- try { rooms.markRead(room, req.user, req.body && req.body.last_id); res.json({ ok: true }); } catch (err) { send(res, err); }++router.post('/:slug/read', requireAuth, readLimit, async (req, res) => {++ const room = await readable(req, res); if (!room) return;++ try { await rooms.markRead(room, req.user, req.body && req.body.last_id); res.json({ ok: true }); } catch (err) { send(res, err); }});--router.get('/:slug/members', optionalAuth, (req, res) => {-- const room = readable(req, res); if (!room) return;-- const moderate = rooms.access(room, req.user || null).moderate;-- res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room, { seen: moderate }).filter((m) => m.role !== 'blocked' || moderate) });++router.get('/:slug/members', optionalAuth, async (req, res) => {++ const room = await readable(req, res); if (!room) return;++ const moderate = (await rooms.access(room, req.user || null)).moderate;++ res.set('Cache-Control', 'private, no-store').json({ members: (await rooms.members(room, { seen: moderate })).filter((m) => m.role !== 'blocked' || moderate) });});router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {-- const room = readable(req, res); if (!room) return;++ const room = await readable(req, res); if (!room) return;try {const name = String((req.body && req.body.username) || '').trim();-- let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;++ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await ctx.getUserByUsername(name) : null;if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });-- const role = rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));++ const role = await rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);-- applyRoomChange(room);++ await applyRoomChange(room);res.json({ ok: true, user_id: target.id, username: target.username, role });} catch (err) { send(res, err); }});--router.get('/:slug/attachments', requireAuth, (req, res) => {-- const room = readable(req, res); if (!room) return;-- if (!rooms.access(room, req.user).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });-- res.set('Cache-Control', 'private, no-store').json({ attachments: rooms.attachments(room) });++router.get('/:slug/attachments', requireAuth, async (req, res) => {++ const room = await readable(req, res); if (!room) return;++ if (!(await rooms.access(room, req.user)).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });++ res.set('Cache-Control', 'private, no-store').json({ attachments: await rooms.attachments(room) });});--router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, (req, res) => {-- const room = readable(req, res); if (!room) return;++router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, async (req, res) => {++ const room = await readable(req, res); if (!room) return;try {const b = req.body || {};-- const out = rooms.attach(room, req.user, { service: b.service, resource: b.resource, title: b.title });-- res.status(out.created ? 201 : 200).json({ ...out, room: rooms.publicRoom(room) });++ const out = await rooms.attach(room, req.user, { service: b.service, resource: b.resource, title: b.title });++ res.status(out.created ? 201 : 200).json({ ...out, room: await rooms.publicRoom(room) });} catch (err) { send(res, err); }});--router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, attachLimit, (req, res) => {-- const room = readable(req, res); if (!room) return;-- try { res.json({ ok: true, ...rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }++router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, attachLimit, async (req, res) => {++ const room = await readable(req, res); if (!room) return;++ try { res.json({ ok: true, ...await rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }});module.exports = router;@@@ -38,7 +38,7 @@ function enabled() {function build() {const tokenClient = {-- authHeaders: () => serviceAuth.headers(AUDIENCE, SCOPE),++ authHeaders: async () => await serviceAuth.headers(AUDIENCE, SCOPE),invalidate: () => serviceAuth.invalidate(AUDIENCE, SCOPE),};const vip = createVipClient({@@@ -23,10 +23,10 @@ function dayOf(ts) {}/** Public, indexable pages: the fixed ones plus every public room, each with a real lastmod. */--function publicPages() {-- const pages = rooms.list(null, { limit: 100 }).public;++async function publicPages() {++ const pages = (await rooms.list(null, { limit: 100 })).public;const out = [-- { path: '/', changefreq: 'hourly', priority: 1.0, lastmod: () => newestGlobalMessage() },++ { path: '/', changefreq: 'hourly', priority: 1.0, lastmod: async () => await newestGlobalMessage() },{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },{ path: '/updates', changefreq: 'daily', priority: 0.5 },];@@@ -36,9 +36,9 @@ function publicPages() {return out;}--function newestGlobalMessage() {++async function newestGlobalMessage() {try {-- const row = historyStore.page('global', { limit: 1 }).messages[0];++ const row = (await historyStore.page('global', { limit: 1 })).messages[0];return row ? dayOf(row.timestamp) : null;} catch { return null; }}@@@ -101,13 +101,12 @@ function createDiscoveryRoutes({ config }) {}));});-- router.get('/sitemap.xml', (req, res) => {-- const urls = publicPages().map((p) => ({-- loc: abs(p.path),-- ...(p.lastmod && p.lastmod() ? { lastmod: p.lastmod() } : {}),-- changefreq: p.changefreq,-- priority: p.priority,-- }));++ router.get('/sitemap.xml', async (req, res) => {++ const urls = [];++ for (const p of await publicPages()) {++ const lastmod = p.lastmod ? await p.lastmod() : null;++ urls.push({ loc: abs(p.path), ...(lastmod ? { lastmod } : {}), changefreq: p.changefreq, priority: p.priority });++ }res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));});@@@ -113,22 +113,22 @@ function createWebRoutes({ config }) {const site = config.web.baseUrl;const siteOrigin = (() => { try { return new URL(site).origin; } catch { return null; } })();const webConfig = { ...config, baseUrl: site };-- const viewers = { resolve: async (req) => viewerOf(req) };++ const viewers = { resolve: async (req) => await viewerOf(req) };const formLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false });const sameSite = (req, res, next) => {const o = req.get('origin');if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');next();};-- const countsOf = (actor) => {++ const countsOf = async (actor) => {if (actor.kind !== 'user') return {};-- const n = (fn) => { try { return Number(fn()) || 0; } catch { return 0; } };-- return { messages: n(() => dm.getTotalUnread(actor.user.id)), rooms: n(() => require('../rooms/rooms').unreadTotal(actor.user)) };++ const n = async (fn) => { try { return Number(await fn()) || 0; } catch { return 0; } };++ return { messages: await n(async () => await dm.getTotalUnread(actor.user.id)), rooms: await n(async () => await require('../rooms/rooms').unreadTotal(actor.user)) };};const page = async (req, res, status, o) => {const actor = o.actor || await viewerOf(req);res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')-- .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: countsOf(actor), ...o }));++ .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), ...o }));};const needUser = async (req, res) => {const actor = await viewerOf(req);@@@ -144,9 +144,9 @@ function createWebRoutes({ config }) {// ── Global chat ──router.get('/', async (req, res) => {const actor = await viewerOf(req);-- const { messages, latest_id } = historyStore.page('global', { limit: 60 });++ const { messages, latest_id } = await historyStore.page('global', { limit: 60 });// Not the lines of people this reader blocked on the network (chat/network-blocks.js).-- const hidden = actor.kind === 'user' ? require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();++ const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));const composer = actor.kind === 'user'? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">@@@ -187,16 +187,16 @@ ${composer}router.get('/messages', async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const me = actor.user;-- const convs = dm.getConversations(me.id);-- const rows = convs.map((c) => {-- const others = dm.getParticipants(c.id).filter((p) => p.id !== me.id);++ const convs = await dm.getConversations(me.id);++ const rows = (await Promise.all(convs.map(async (c) => {++ const others = (await dm.getParticipants(c.id)).filter((p) => p.id !== me.id);const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';const t = hhmm(c.last_message_at);return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;-- });++ })));const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';-- const blocked = dm.getBlockedUsers(me.id);++ const blocked = await dm.getBlockedUsers(me.id);const blockedList = blocked.length ? `<details class="oc-blocked"><summary>People you blocked (${blocked.length})</summary><ul class="oc-members">${blocked.map((b) => `<li><strong>${esc(b.display_name || b.username)}</strong> <span class="oc-muted">@${esc(b.username || '')}</span><form class="oc-inline" method="post" action="/messages/unblock/${Number(b.id)}"><button type="submit" class="oc-link">Unblock</button></form></li>`).join('')}</ul></details>` : '';const unread = convs.reduce((n, c) => n + (Number(c.unread_count) || 0), 0);await page(req, res, 200, {@@@ -213,7 +213,7 @@ ${blockedList}`,router.post('/messages/unblock/:userId', formLimit, sameSite, async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const id = parseInt(req.params.userId, 10);-- if (Number.isInteger(id) && id > 0) dm.unblockUser(actor.user.id, id);++ if (Number.isInteger(id) && id > 0) await dm.unblockUser(actor.user.id, id);const back = /^\/messages\/\d+$/.test(String(req.body && req.body.back)) ? req.body.back : '/messages?unblocked=1';res.redirect(303, back);});@@@ -221,7 +221,7 @@ ${blockedList}`,router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {const actor = await needUser(req, res); if (!actor) return;const name = String((req.body && req.body.username) || '').trim();-- let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;++ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await ctx.getUserByUsername(name) : null;if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);if (!target) return res.redirect(303, `/messages?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);req.headers.authorization = `Bearer ${req.cookies.ov_token}`;@@@ -236,17 +236,17 @@ ${blockedList}`,const actor = await needUser(req, res); if (!actor) return;const me = actor.user;const id = parseInt(req.params.id, 10);-- if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });-- const others = dm.getParticipants(id).filter((p) => p.id !== me.id);-- const conv = dm.getConversation(id);++ if (!Number.isInteger(id) || !await dm.isParticipant(id, me.id)) return await page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });++ const others = (await dm.getParticipants(id)).filter((p) => p.id !== me.id);++ const conv = await dm.getConversation(id);const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';-- const msgs = dm.getMessages(id, 60).slice().sort((a, b) => a.id - b.id);-- try { dm.markRead(id, me.id); } catch { /* best effort */ }++ const msgs = (await dm.getMessages(id, 60)).slice().sort((a, b) => a.id - b.id);++ try { await dm.markRead(id, me.id); } catch { /* best effort */ }const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';// A 1:1 conversation: block or unblock the other person here; a block (either way) closes the composer.const other = conv && !conv.is_group && others.length === 1 ? others[0] : null;-- const iBlocked = other ? dm.hasBlocked(me.id, other.id) : false;-- const closed = other ? dm.isBlockedEither(me.id, other.id) : false;++ const iBlocked = other ? await dm.hasBlocked(me.id, other.id) : false;++ const closed = other ? await dm.isBlockedEither(me.id, other.id) : false;const otherName = other ? esc(other.display_name || other.username) : '';let composer = `<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`;if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;@@@ -265,11 +265,11 @@ ${composer}${tools}`,const actor = await needUser(req, res); if (!actor) return;const me = actor.user;const id = parseInt(req.params.id, 10);-- if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return res.redirect(303, '/messages');-- const conv = dm.getConversation(id);-- const others = dm.getParticipants(id).filter((p) => p.id !== me.id);++ if (!Number.isInteger(id) || !await dm.isParticipant(id, me.id)) return res.redirect(303, '/messages');++ const conv = await dm.getConversation(id);++ const others = (await dm.getParticipants(id)).filter((p) => p.id !== me.id);if (!conv || conv.is_group || others.length !== 1) return res.redirect(303, `/messages/${id}?error=${encodeURIComponent('Only a one-to-one conversation has someone to block')}`);-- dm.blockUser(me.id, others[0].id);++ await dm.blockUser(me.id, others[0].id);res.redirect(303, `/messages/${id}`);});@@@ -372,12 +372,12 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;};-- const applyRoomChange = (room) => roomRoutes.applyRoomChange(room);++ const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);router.get('/rooms', async (req, res) => {const actor = await viewerOf(req);const me = actor.kind === 'user' ? actor.user : null;-- const { public: pub, mine } = rooms.list(me);++ const { public: pub, mine } = await rooms.list(me);const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';const staff = !!me && permissions.can(me, 'staff.moderation.chat');const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new">@@@ -407,15 +407,15 @@ ${create}`,const actor = await needUser(req, res); if (!actor) return;const b = req.body || {};const kind = rooms.KINDS.includes(b.kind) ? b.kind : 'community';-- const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public', kind, join_role: kind === 'call' ? b.join_role || null : null });++ const room = await rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public', kind, join_role: kind === 'call' ? b.join_role || null : null });res.redirect(303, `/r/${room.slug}`);}));const loadRoom = async (req, res) => {const actor = await viewerOf(req);const me = actor.kind === 'user' ? actor.user : null;-- const room = rooms.bySlug(req.params.slug);-- const can = room ? rooms.access(room, me) : null;++ const room = await rooms.bySlug(req.params.slug);++ const can = room ? await rooms.access(room, me) : null;if (!room || !can.read) { await page(req, res, 404, { actor, title: 'No such room', path: req.path, robots: 'noindex', body: '<h1>No such room</h1><p><a href="/rooms">All rooms</a></p>' }); return null; }req.roomBack = `/r/${room.slug}`;return { actor, me, room, can };@@@ -442,8 +442,8 @@ ${people}router.get('/r/:slug', async (req, res) => {const x = await loadRoom(req, res); if (!x) return;const { actor, me, room, can } = x;-- const msgs = rooms.history(room, { limit: 60 });-- if (me && can.role) rooms.markRead(room, me);++ const msgs = await rooms.history(room, { limit: 60 });++ if (me && can.role) await rooms.markRead(room, me);const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';let composer;@@@ -466,7 +466,7 @@ ${people}...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },body: `<p><a href="/rooms">← Rooms</a></p><h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>--<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>++<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${(await rooms.publicRoom(room)).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>${error}${isCall ? callPanel(room, can, me) : ''}<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>@@@ -476,41 +476,41 @@ ${composer}${tools}`,router.post('/r/:slug', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;-- const room = rooms.bySlug(req.params.slug);-- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}`;-- const message = rooms.post(room, actor.user, req.body && req.body.message);++ const message = await rooms.post(room, actor.user, req.body && req.body.message);require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });res.redirect(303, `/r/${room.slug}#oc-compose`);}));router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;-- const room = rooms.bySlug(req.params.slug);-- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}`;-- rooms.join(room, actor.user);-- applyRoomChange(room);++ await rooms.join(room, actor.user);++ await applyRoomChange(room);res.redirect(303, `/r/${room.slug}`);}));router.post('/r/:slug/leave', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;-- const room = rooms.bySlug(req.params.slug);++ const room = await rooms.bySlug(req.params.slug);if (!room) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}`;-- rooms.leave(room, actor.user);++ await rooms.leave(room, actor.user);require('../chat/chat-server').removeFromRoom(room.id, actor.user.id);-- applyRoomChange(room);++ await applyRoomChange(room);res.redirect(303, '/rooms');}));router.post('/r/:slug/delete/:id', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;-- const room = rooms.bySlug(req.params.slug);-- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}`;-- const id = rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));++ const id = await rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });res.redirect(303, `/r/${room.slug}`);}));@@@ -521,7 +521,7 @@ ${composer}${tools}`,if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);const saved = req.query.saved ? notice('ok', 'Saved.') : '';const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';-- const members = rooms.members(room, { seen: true });++ const members = await rooms.members(room, { seen: true });const kindRoles = rooms.KIND_ROLES[room.kind] || rooms.KIND_ROLES.community;const choices = [...kindRoles.filter((r) => r !== 'owner' && (r !== 'mod' || can.manage)), 'none'];const roleOption = (r, current) => `<option value="${r}"${r === current ? ' selected' : ''}>${r === 'none' ? 'remove' : ROLE_LABEL[r] || r}</option>`;@@@ -530,7 +530,7 @@ ${composer}${tools}`,: (m.role === 'mod' && !can.manage) ? '<span class="oc-muted">moderator</span>': `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select> <button type="submit">Set</button></form>`;const addRole = rooms.joinRoleOf(room);-- const attachments = can.manage ? rooms.attachments(room) : [];++ const attachments = can.manage ? await rooms.attachments(room) : [];const communityUrl = config.web.communityUrl;const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';await page(req, res, 200, {@@@ -559,41 +559,41 @@ ${attachList}`,router.post('/r/:slug/settings', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;-- const room = rooms.bySlug(req.params.slug);-- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}/settings`;const b = req.body || {};-- rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined });-- applyRoomChange(room);++ await rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined });++ await applyRoomChange(room);res.redirect(303, `/r/${room.slug}/settings?saved=1`);}));router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;-- const room = rooms.bySlug(req.params.slug);-- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}/settings`;const name = String((req.body && req.body.username) || '').trim();-- let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;++ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await ctx.getUserByUsername(name) : null;if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);if (!target) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);-- const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || rooms.joinRoleOf(room)));++ const role = await rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || rooms.joinRoleOf(room)));if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) require('../chat/chat-server').removeFromRoom(room.id, target.id);-- applyRoomChange(room);++ await applyRoomChange(room);res.redirect(303, `/r/${room.slug}/settings?saved=1`);}));router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;-- const room = rooms.bySlug(req.params.slug);-- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}/settings`;-- rooms.detach(room, actor.user, req.params.service, req.params.resource);++ await rooms.detach(room, actor.user, req.params.service, req.params.resource);res.redirect(303, `/r/${room.slug}/settings?saved=1`);}));// ── What shipped, robots, sitemap ──-- router.get('/updates', (req, res) => page(req, res, 200, {++ router.get('/updates', async (req, res) => await page(req, res, 200, {title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,}));@@@ -30,15 +30,15 @@ t('boot', async () => {t('export, then erase and confirm', async () => {const accountData = require('../server/chat/account-data');const d = require('../server/db/database').getDb();-- d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'dana', 'hi otto', ?, ?), (?, 'otto', 'hi dana', ?, ?), (?, 'dana_old', 'old me', ?, ?)")++ await d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'dana', 'hi otto', ?, ?), (?, 'otto', 'hi dana', ?, ?), (?, 'dana_old', 'old me', ?, ?)").run(dana.id, otto.id, dana.subject_id, otto.id, dana.id, otto.subject_id, old.id, otto.id, old.subject_id);const pair = (await h.http('POST', '/api/dm/conversations', { token: dana.token, body: { user_ids: [otto.id] } })).body.conversation.id;assert.ok((await h.http('POST', `/api/dm/conversations/${pair}/messages`, { token: dana.token, body: { message: 'psst' } })).status < 300);assert.ok((await h.http('POST', `/api/dm/conversations/${pair}/messages`, { token: otto.token, body: { message: 'what' } })).status < 300);require('../server/chat/network-blocks').ensureSchema && require('../server/chat/network-blocks').ensureSchema();-- d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(dana.subject_id, otto.subject_id, otto.subject_id, dana.subject_id);-- d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id, actor_subject_id) VALUES ('timeout', ?, ?, ?)").run(otto.id, dana.id, otto.subject_id);-- d.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (?, 'veteran')").run(dana.id);++ await d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(dana.subject_id, otto.subject_id, otto.subject_id, dana.subject_id);++ await d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id, actor_subject_id) VALUES ('timeout', ?, ?, ?)").run(otto.id, dana.id, otto.subject_id);++ await d.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (?, 'veteran')").run(dana.id);const sent = [];let failNext = false;@@@ -61,14 +61,14 @@ t('export, then erase and confirm', async () => {const del = ev('network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject: dana.subject_id, aliases: [old.subject_id], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() });failNext = true;await assert.rejects(accountData.apply(del, { send }), /confirmation refused: 503/);-- const q = (sql, ...a) => d.prepare(sql).all(...a);-- assert.deepStrictEqual(q('SELECT message FROM chat_messages ORDER BY id').map((m) => m.message), ['hi dana'], "hers and the alias's gone; otto's stays");-- assert.deepStrictEqual(q('SELECT message FROM dm_messages ORDER BY id').map((m) => m.message), ['what'], 'the DM she sent goes, his reply stays');-- assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ?', pair).map((p) => p.user_id), [otto.id]);-- assert.strictEqual(q('SELECT * FROM network_blocks').length, 0, 'blocks both ways');-- assert.strictEqual(q('SELECT * FROM moderation_actions').length, 1, 'moderation history stays');-- assert.strictEqual(q('SELECT * FROM user_tags').length, 0, 'the tags table is Chat’s and goes');-- assert.strictEqual(d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id).username, `deleted-${dana.id}`);++ const q = async (sql, ...a) => await d.prepare(sql).all(...a);++ assert.deepStrictEqual((await q('SELECT message FROM chat_messages ORDER BY id')).map((m) => m.message), ['hi dana'], "hers and the alias's gone; otto's stays");++ assert.deepStrictEqual((await q('SELECT message FROM dm_messages ORDER BY id')).map((m) => m.message), ['what'], 'the DM she sent goes, his reply stays');++ assert.deepStrictEqual((await q('SELECT user_id FROM dm_participants WHERE conversation_id = ?', pair)).map((p) => p.user_id), [otto.id]);++ assert.strictEqual((await q('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');++ assert.strictEqual((await q('SELECT * FROM moderation_actions')).length, 1, 'moderation history stays');++ assert.strictEqual((await q('SELECT * FROM user_tags')).length, 0, 'the tags table is Chat’s and goes');++ assert.strictEqual((await d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id)).username, `deleted-${dana.id}`);assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');const conf = sent[1];assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));@@@ -57,16 +57,16 @@ t('the next minute opens the window again', async () => {t('a write has its own number: 20 REST sends a minute, the 21st refused before it is stored', async () => {clock = Date.UTC(2026, 8, 27, 12, 5, 0);-- const stored = () => h.db.get("SELECT COUNT(*) AS n FROM chat_messages WHERE message LIKE 'limits %'").n;++ const stored = async () => (await h.db.get("SELECT COUNT(*) AS n FROM chat_messages WHERE message LIKE 'limits %'")).n;for (let i = 0; i < 20; i++) {const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: `limits ${i} from alice` } });assert.strictEqual(r.status, 200, `send ${i + 1}: ${r.text}`);}-- const before = stored();++ const before = await stored();const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'limits one more' } });assert.deepStrictEqual([r.status, r.body.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);assert.ok(r.body.detail.includes('chat.message.send'), r.body.detail);-- assert.strictEqual(stored(), before, 'nothing stored');++ assert.strictEqual(await stored(), before, 'nothing stored');assert.strictEqual((await h.http('POST', '/api/chat/send', { token: bob.token, body: { message: 'limits hi from bob' } })).status, 200, 'another person still sends');assert.strictEqual((await h.http('POST', '/api/chat/send', { token: 'nope', body: { message: 'x' } })).status, 401, 'a bad token: 401 from auth');});nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * The audio queue's claim (plan T3 decision 3): two pumps — two module instances standing for two Chat processes,++ * each with its own pool connections on one database — race over the same queued requests. The claim is one atomic++ * UPDATE … WHERE state = 'queued' RETURNING *, so every request plays exactly once; the synth runs outside any++ * transaction. And enqueue's dedupe is the unique (room, dedupe_key) index: concurrent enqueues of one key store++ * one row. On PostgreSQL (npm run test:pg) the two pumps really run on separate connections.++ */++const assert = require('assert');++const { suite } = require('./helpers');++++const t = suite('audio-claim');++const db = require('../server/db/database');++const QUEUE = require.resolve('../server/chat/audio-queue');++++/** A fresh copy of server/chat/audio-queue.js (its own rooms and timers), sharing the database module. */++function instance(name, plays) {++ delete require.cache[QUEUE];++ const aq = require(QUEUE);++ delete require.cache[QUEUE];++ aq.init({++ performers: {++ tts: async () => {++ await new Promise((r) => setTimeout(r, Math.random() * 15)); // a synth: outside any transaction++ return { frame: { type: 'tts-audio', audio: '' }, durationMs: 1 };++ },++ },++ deliver: (row, frame) => { if (frame.type === 'tts-audio') plays.push({ by: name, id: row.id }); },++ });++ return aq;++}++++const plays = [];++let a, b;++++t('boot', async () => {++ await db.initDb();++ a = instance('a', plays);++ b = instance('b', plays);++ assert.notStrictEqual(a, b);++});++++t('two pumps over 40 rooms: every request plays exactly once', async () => {++ const ROOMS = 40;++ const ids = [];++ // Queued rows no pump has seen yet (enqueue would start its own pump at once).++ for (let i = 0; i < ROOMS; i++) {++ const r = await db.run(`INSERT INTO audio_requests (room, stream_id, kind, state, label, payload, created_at)++ VALUES (?, ?, 'tts', 'queued', ?, '{}', ?)`, [`stream:${9000 + i}`, 9000 + i, `r${i}`, Date.now()]);++ ids.push(Number(r.lastInsertRowid));++ }++ // Both pumps on every room at once.++ await Promise.all(ids.map((_, i) => Promise.all([a.pump(`stream:${9000 + i}`), b.pump(`stream:${9000 + i}`)])));++ const until = Date.now() + 10000;++ while (plays.length < ROOMS && Date.now() < until) await new Promise((r) => setTimeout(r, 20));++ await new Promise((r) => setTimeout(r, 200)); // a double play would arrive after the first++ const played = plays.map((p) => p.id).sort((x, y) => x - y);++ assert.deepStrictEqual(played, [...ids].sort((x, y) => x - y), 'each request delivered once, none twice');++ const rows = await db.all('SELECT id, state, attempts FROM audio_requests WHERE id = ANY(?) ORDER BY id', [ids]);++ assert.ok(rows.every((r) => ['playing', 'played'].includes(r.state) && r.attempts === 1), JSON.stringify(rows));++ const by = new Set(plays.map((p) => p.by));++ console.log(` (plays by pump: ${[...by].map((n) => `${n} ${plays.filter((p) => p.by === n).length}`).join(', ')})`);++});++++t('a request claimed by one pump is not claimed again', async () => {++ const r = await a.enqueue({ kind: 'tts', streamId: 9100, label: 'once', payload: {} });++ a.stop(); b.stop(); // no pump: claim by hand++ const [x, y] = await Promise.all([a.claim(r.id), b.claim(r.id)]);++ assert.strictEqual([x, y].filter(Boolean).length, 1, 'one claim wins');++ assert.strictEqual((x || y).state, 'playing');++ assert.strictEqual((await db.get('SELECT attempts FROM audio_requests WHERE id = ?', [r.id])).attempts, 1);++});++++t('concurrent enqueues of one dedupe key store one row', async () => {++ const outs = await Promise.all([a, b, a, b].map((q) => q.enqueue({ kind: 'tts', streamId: 9200, label: 'dup', payload: {}, dedupeKey: 'k1' })));++ assert.strictEqual(outs.filter((o) => o.queued).length, 1, JSON.stringify(outs));++ assert.ok(outs.filter((o) => !o.queued).every((o) => o.reason === 'duplicate'));++ assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = 'stream:9200'")).n, 1);++});++++t.run(async () => { if (a) a.stop(); if (b) b.stop(); });@@@ -11,7 +11,6 @@const assert = require('assert');const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');const { boot, suite } = require('./helpers');const t = suite('audio-queue');@@@ -49,11 +48,11 @@ async function joined(user, ip, stream = streamId) {await ws.next((m) => m.type === 'auth');return ws;}--const rows = () => h.db.all('SELECT * FROM audio_requests ORDER BY id');--const byLabel = (label) => h.db.get('SELECT * FROM audio_requests WHERE label = ?', [label]);++const rows = async () => await h.db.all('SELECT * FROM audio_requests ORDER BY id');++const byLabel = async (label) => await h.db.get('SELECT * FROM audio_requests WHERE label = ?', [label]);async function until(fn, ms = 4000) {const end = Date.now() + ms;-- for (;;) { const v = fn(); if (v) return v; if (Date.now() > end) throw new Error('condition not met in time'); await h.sleep(25); }++ for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) throw new Error('condition not met in time'); await h.sleep(25); }}t('boot', async () => {@@@ -68,7 +67,7 @@ t('boot', async () => {listenerUser = h.addUser('listener');chatters = ['ann', 'ben', 'cat', 'dan'].map((n) => h.addUser(n));channelId = h.addChannel(streamer.id, { moderators: [mod.id] });-- h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)++ await h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)streamId = h.addStream(streamer.id, channelId);await h.ctx.sync();listener = await joined(listenerUser, '198.51.100.90');@@@ -103,13 +102,15 @@ t('TTS of chat messages: queued rows, delivered one at a time, each frame carriefirst = await listener.next((m) => m.type === 'tts-audio' && m.message === 'hello from ann');assert.ok(first.request_id > 0);assert.ok(first.audio && first.mimeType === 'audio/wav' && first.ttsKey, 'the frame clients already play');-- assert.strictEqual(aq.getRequest(first.request_id).state, 'playing');-- assert.strictEqual(byLabel('hello from ben').state, 'queued');-- assert.strictEqual(byLabel('hello from cat').state, 'queued');++ assert.strictEqual((await aq.getRequest(first.request_id)).state, 'playing');++ // The chat line is announced before its request is stored: wait for the rows, not just the frame.++ await until(async () => !!(await byLabel('hello from ben')) && !!(await byLabel('hello from cat')), 3000);++ assert.strictEqual((await byLabel('hello from ben')).state, 'queued');++ assert.strictEqual((await byLabel('hello from cat')).state, 'queued');assert.ok(await listener.none((m) => m.type === 'tts-audio' && m.message === 'hello from ben', 400), 'the next waits for the one playing');second = await listener.next((m) => m.type === 'tts-audio' && m.message === 'hello from ben', 3000);assert.ok(Date.now() - t0 >= 1200, 'paced by the clip length');-- const r1 = aq.getRequest(first.request_id);++ const r1 = await aq.getRequest(first.request_id);assert.strictEqual(r1.state, 'played');assert.strictEqual(r1.duration_ms, 1200);assert.strictEqual(r1.kind, 'tts');@@@ -120,7 +121,7 @@ t('only the broadcaster and moderators skip: /skiptts skips the playing clip andconst v = await joined(viewer, '198.51.100.110');v.sendJson({ type: 'chat', message: '/skiptts' });await v.next((m) => m.type === 'system' && m.message === 'You do not have permission.');-- assert.strictEqual(aq.getRequest(second.request_id).state, 'playing');++ assert.strictEqual((await aq.getRequest(second.request_id)).state, 'playing');const s = await joined(streamer, '198.51.100.111');const at = Date.now();@@@ -129,12 +130,12 @@ t('only the broadcaster and moderators skip: /skiptts skips the playing clip andassert.match(reply.message, new RegExp(`#${second.request_id} from Ben`));const stop = await listener.next((m) => m.type === 'audio-skip');assert.strictEqual(stop.request_id, second.request_id);-- const row = aq.getRequest(second.request_id);++ const row = await aq.getRequest(second.request_id);assert.strictEqual(row.state, 'skipped');assert.strictEqual(row.actor, 'user:streamer');third = await listener.next((m) => m.type === 'tts-audio' && m.message === 'hello from cat');assert.ok(Date.now() - at < 900, 'no waiting out the skipped clip');-- const log = h.db.get("SELECT * FROM moderation_actions WHERE action_type = 'tts_skip' ORDER BY id DESC LIMIT 1");++ const log = await h.db.get("SELECT * FROM moderation_actions WHERE action_type = 'tts_skip' ORDER BY id DESC LIMIT 1");assert.strictEqual(log.actor_user_id, streamer.id);});@@@ -157,8 +158,8 @@ t('the queue over REST: list, skip by id, clear — moderators yes, others 403',assert.strictEqual((await h.http('POST', '/api/tts/queue/skip', { token: viewer.token, body: { stream_id: streamId, id: two } })).status, 403);const sk = await h.http('POST', '/api/tts/queue/skip', { token: mod.token, body: { stream_id: streamId, id: two } });assert.strictEqual(sk.status, 200, sk.text);-- assert.strictEqual(aq.getRequest(two).state, 'skipped');-- assert.strictEqual(aq.getRequest(third.request_id).state, 'playing', 'skipping a waiting request leaves the playing one');++ assert.strictEqual((await aq.getRequest(two)).state, 'skipped');++ assert.strictEqual((await aq.getRequest(third.request_id)).state, 'playing', 'skipping a waiting request leaves the playing one');assert.ok(await listener.none((m) => m.type === 'audio-skip' && m.request_id === two, 150), 'nothing to stop on clients for a request never delivered');const cl = await h.http('POST', '/api/tts/queue/clear', { token: mod.token, body: { stream_id: streamId } });@@@ -169,7 +170,7 @@ t('the queue over REST: list, skip by id, clear — moderators yes, others 403',assert.strictEqual(cl.body.queue.playing, null);assert.deepStrictEqual(cl.body.queue.queued, []);assert.ok(await listener.none((m) => m.type === 'tts-audio' && /^bot line/.test(m.message), 300), 'nothing cleared is read');-- assert.ok(h.db.get("SELECT 1 FROM moderation_actions WHERE action_type = 'tts_clear' AND actor_user_id = ?", [mod.id]));++ assert.ok(await h.db.get("SELECT 1 FROM moderation_actions WHERE action_type = 'tts_clear' AND actor_user_id = ?", [mod.id]));assert.strictEqual((await h.http('POST', '/api/tts/queue/skip', { token: mod.token, body: { stream_id: streamId } })).status, 409, 'nothing to skip');});@@@ -178,37 +179,38 @@ t('failures are recorded and the queue moves on', async () => {const BRIDGE = h.serviceToken(['chat.live_bridge.write', 'chat.message.send']);await h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'b', ops: ['no audio', 'throws', 'fine'].map((l, i) => ({ seq: i + 1, op: 'synthesizeAndBroadcastTTS', args: [streamId, 'ChatBot', l, null, 'ai', `ai:bot${i}`, null, `mf${i}`] })) } });await listener.next((m) => m.type === 'tts-audio' && m.message === 'fine');-- assert.strictEqual(byLabel('no audio').state, 'failed');-- assert.strictEqual(byLabel('no audio').error, 'no audio');-- assert.strictEqual(byLabel('throws').state, 'failed');-- assert.strictEqual(byLabel('throws').error, 'voice service down');-- assert.strictEqual(byLabel('fine').state, 'playing');++ assert.strictEqual((await byLabel('no audio')).state, 'failed');++ assert.strictEqual((await byLabel('no audio')).error, 'no audio');++ assert.strictEqual((await byLabel('throws')).state, 'failed');++ assert.strictEqual((await byLabel('throws')).error, 'voice service down');++ assert.strictEqual((await byLabel('fine')).state, 'playing');});t('the playing client reports: failed with its reason; final states stay final', async () => {-- const fine = byLabel('fine');++ const fine = await byLabel('fine');const bad = await h.http('POST', `/api/tts/queue/${fine.id}/report`, { token: streamer.token, body: { stream_id: streamId, state: 'skipped' } });assert.strictEqual(bad.status, 400);const rep = await h.http('POST', `/api/tts/queue/${fine.id}/report`, { token: streamer.token, body: { stream_id: streamId, state: 'failed', error: 'NotAllowedError' } });assert.strictEqual(rep.status, 200, rep.text);-- assert.strictEqual(aq.getRequest(fine.id).state, 'failed');-- assert.strictEqual(aq.getRequest(fine.id).error, 'NotAllowedError');++ assert.strictEqual((await aq.getRequest(fine.id)).state, 'failed');++ assert.strictEqual((await aq.getRequest(fine.id)).error, 'NotAllowedError');assert.strictEqual((await h.http('POST', `/api/tts/queue/${fine.id}/report`, { token: streamer.token, body: { stream_id: streamId, state: 'played' } })).status, 409, 'final states stay final');});t('a keyed request is queued once; a requester’s limit holds', async () => {synthPlan = [3, 3, 3, 3, 3, 3];-- const before = rows().length;-- const a = h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', 'same message', null, null, 'user:ann', null, 'm777');-- const b = h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', 'same message', null, null, 'user:ann', null, 'm777');++ const before = (await rows()).length;++ const a = await h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', 'same message', null, null, 'user:ann', null, 'm777');++ const b = await h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', 'same message', null, null, 'user:ann', null, 'm777');assert.strictEqual(a.queued, true);assert.deepStrictEqual(b, { queued: false, reason: 'duplicate' });// tts_max_queue_per_user defaults to 3 (queued or playing).-- const more = [1, 2, 3].map((n) => h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', `more ${n}`, null, null, 'user:ann', null, `m78${n}`));++ const more = [];++ for (const n of [1, 2, 3]) more.push(await h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', `more ${n}`, null, null, 'user:ann', null, `m78${n}`));assert.deepStrictEqual(more.map((x) => x.queued), [true, true, false]);assert.strictEqual(more[2].reason, 'full');-- assert.strictEqual(rows().length, before + 3);-- assert.strictEqual(h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', '.not read', null, null, 'user:ann', null, 'm790'), undefined, "'.' opts a message out");++ assert.strictEqual((await rows()).length, before + 3);++ assert.strictEqual(await h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', '.not read', null, null, 'user:ann', null, 'm790'), undefined, "'.' opts a message out");const s = await joined(streamer, '198.51.100.112');s.sendJson({ type: 'chat', message: '/cleartts' });await s.next((m) => m.type === 'system' && /^Cleared 3 TTS\/sound requests/.test(m.message));@@@ -217,7 +219,7 @@ t('a keyed request is queued once; a requester’s limit holds', async () => {t('channel !sounds go through the same queue (announce at once, audio in turn)', async () => {const file = path.join(process.env.SOUNDS_PATH, 'honk-test.wav');fs.writeFileSync(file, wav(0.8));-- h.db.createChannelSound({ channel_owner_id: streamer.id, command: 'honk', url: file, mime: 'audio/wav', duration_seconds: 0.8 });++ await h.db.createChannelSound({ channel_owner_id: streamer.id, command: 'honk', url: file, mime: 'audio/wav', duration_seconds: 0.8 });const c = await joined(chatters[3], '198.51.100.113');c.sendJson({ type: 'chat', message: '!honk' });const ann = await listener.next((m) => m.type === 'chat' && m.message_type === 'channel-sound');@@@ -225,41 +227,41 @@ t('channel !sounds go through the same queue (announce at once, audio in turn)',const audio = await listener.next((m) => m.type === 'soundboard-audio' && m.title === '!honk');assert.strictEqual(audio.source, 'channel-sound');assert.strictEqual(Buffer.from(audio.audio, 'base64').length, fs.statSync(file).size);-- const row = aq.getRequest(audio.request_id);++ const row = await aq.getRequest(audio.request_id);assert.strictEqual(row.kind, 'channel-sound');assert.strictEqual(row.duration_ms, 800);assert.strictEqual(row.dedupe_key, `m${ann.id}`);-- await until(() => aq.getRequest(audio.request_id).state === 'played', 3000);++ await until(async () => (await aq.getRequest(audio.request_id)).state === 'played', 3000);});// ── Restart: Chat as its own process ────────────────────────────────────────────────────────────--let chat, port, dbFile;--const raw = (fn) => { const d = new Database(dbFile); try { return fn(d); } finally { d.close(); } };++let chat, port;++// This process's handle on the database the spawned Chat serves from (test/helpers/pg-preload.mjs).++const raw = async (fn) => await fn(h.db.getDb());t('restart: what was playing is finished, what waited plays in order once, stale requests expire', async () => {tts.synthesize = realSynth.synthesize;tts.synthesizeUserVoice = realSynth.synthesizeUserVoice;-- dbFile = path.join(h.tmp, 'chat.db');for (const w of sockets) { try { w.close(); } catch { /* */ } }await h.detach();// Long enough clips that the queue is still full when Chat stops.const file = path.join(process.env.SOUNDS_PATH, 'long-test.wav');fs.writeFileSync(file, wav(2));-- raw((d) => {-- d.prepare("INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds) VALUES (?, 'long', ?, 'audio/wav', 2)").run(streamer.id, file);++ await raw(async (d) => {++ await d.prepare("INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds) VALUES (?, 'long', ?, 'audio/wav', 2)").run(streamer.id, file);// Left over from an earlier life: one queued an hour ago, one that crashed Chat three times.const ins = d.prepare("INSERT INTO audio_requests (room, stream_id, kind, state, label, payload, created_at, attempts) VALUES (?, ?, 'channel-sound', 'queued', ?, ?, ?, ?)");-- ins.run(`stream:${streamId}`, streamId, 'stale', JSON.stringify({ file, title: '!stale' }), Date.now() - 3600e3, 0);-- ins.run(`stream:${streamId}`, streamId, 'crashy', JSON.stringify({ file, title: '!crashy' }), Date.now(), 3);++ await ins.run(`stream:${streamId}`, streamId, 'stale', JSON.stringify({ file, title: '!stale' }), Date.now() - 3600e3, 0);++ await ins.run(`stream:${streamId}`, streamId, 'crashy', JSON.stringify({ file, title: '!crashy' }), Date.now(), 3);});port = await h.freePort();chat = await h.spawnChat({ port });-- assert.strictEqual(raw((d) => d.prepare("SELECT state, error FROM audio_requests WHERE label = 'stale'").get()).error, 'expired');++ assert.strictEqual((await raw(async (d) => await d.prepare("SELECT state, error FROM audio_requests WHERE label = 'stale'").get())).error, 'expired');// The room's queue is held until it has listeners again (a second after the first rejoins).await h.sleep(300);-- assert.strictEqual(raw((d) => d.prepare("SELECT state FROM audio_requests WHERE label = 'crashy'").get().state), 'queued', 'held for listeners');++ assert.strictEqual(await raw(async (d) => (await d.prepare("SELECT state FROM audio_requests WHERE label = 'crashy'").get()).state), 'queued', 'held for listeners');const ears = await joined(listenerUser, '198.51.100.120');-- await until(() => raw((d) => d.prepare("SELECT state FROM audio_requests WHERE label = 'crashy'").get().state) === 'failed');-- assert.strictEqual(raw((d) => d.prepare("SELECT error FROM audio_requests WHERE label = 'crashy'").get()).error, 'gave up after repeated attempts');++ await until(async () => await raw(async (d) => (await d.prepare("SELECT state FROM audio_requests WHERE label = 'crashy'").get()).state) === 'failed');++ assert.strictEqual((await raw(async (d) => await d.prepare("SELECT error FROM audio_requests WHERE label = 'crashy'").get())).error, 'gave up after repeated attempts');for (const [i, c] of chatters.slice(0, 3).entries()) {const ws = await joined(c, `198.51.100.${121 + i}`);@@@ -269,8 +271,8 @@ t('restart: what was playing is finished, what waited plays in order once, stale}const firstClip = await ears.next((m) => m.type === 'soundboard-audio' && m.title === '!long');// The announce comes before the durable insert: wait for all three rows, not just the first clip.-- await until(() => raw((d) => d.prepare("SELECT COUNT(*) AS n FROM audio_requests WHERE label = '!long'").get().n) === 3, 3000);-- const queued = raw((d) => d.prepare("SELECT id, state FROM audio_requests WHERE label = '!long' ORDER BY id").all());++ await until(async () => await raw(async (d) => (await d.prepare("SELECT COUNT(*) AS n FROM audio_requests WHERE label = '!long'").get()).n) === 3, 3000);++ const queued = await raw(async (d) => await d.prepare("SELECT id, state FROM audio_requests WHERE label = '!long' ORDER BY id").all());assert.deepStrictEqual(queued.map((x) => x.state), ['playing', 'queued', 'queued']);assert.strictEqual(queued[0].id, firstClip.request_id);@@@ -278,14 +280,14 @@ t('restart: what was playing is finished, what waited plays in order once, staleassert.deepStrictEqual(exit, { code: 0, signal: null }, chat.log);chat = await h.spawnChat({ port });await h.sleep(300);-- assert.strictEqual(raw((d) => d.prepare('SELECT state FROM audio_requests WHERE id = ?').get(queued[1].id).state), 'queued', 'nothing plays into an empty room');++ assert.strictEqual(await raw(async (d) => (await d.prepare('SELECT state FROM audio_requests WHERE id = ?').get(queued[1].id)).state), 'queued', 'nothing plays into an empty room');const ears2 = await joined(listenerUser, '198.51.100.130');const got = [];got.push(await ears2.next((m) => m.type === 'soundboard-audio' && m.title === '!long', 4000));got.push(await ears2.next((m) => m.type === 'soundboard-audio' && m.title === '!long', 5000));assert.deepStrictEqual(got.map((m) => m.request_id), [queued[1].id, queued[2].id], 'the waiting clips, in order');assert.ok(await ears2.none((m) => m.type === 'soundboard-audio' && m.request_id === queued[0].id, 100), 'the clip delivered before the restart is not played again');-- const after = raw((d) => d.prepare("SELECT id, state, error FROM audio_requests WHERE label = '!long' ORDER BY id").all());++ const after = await raw(async (d) => await d.prepare("SELECT id, state, error FROM audio_requests WHERE label = '!long' ORDER BY id").all());assert.strictEqual(after[0].state, 'played');assert.strictEqual(after[0].error, 'delivered before a restart');assert.strictEqual(after[1].state, 'played');@@@ -295,7 +297,7 @@ t('restart: what was playing is finished, what waited plays in order once, staleconst s = await joined(mod, '198.51.100.131');s.sendJson({ type: 'chat', message: '/cleartts' });await s.next((m) => m.type === 'system' && /^Cleared 1 TTS\/sound request\./.test(m.message));-- assert.strictEqual(raw((d) => d.prepare('SELECT state FROM audio_requests WHERE id = ?').get(queued[2].id).state), 'skipped');++ assert.strictEqual(await raw(async (d) => (await d.prepare('SELECT state FROM audio_requests WHERE id = ?').get(queued[2].id)).state), 'skipped');});t.run(async () => {@@@ -43,8 +43,8 @@ t('only Live’s service token, only from loopback', async () => {t('sending a chat message needs chat.message.send; without it only those ops are refused', async () => {const APP = 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';-- const before = h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n;-- const outboxBefore = h.db.get('SELECT COUNT(*) AS n FROM events_outbox').n;++ const before = (await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n;++ const outboxBefore = (await h.db.get('SELECT COUNT(*) AS n FROM events_outbox')).n;const commit = { hash: 'f'.repeat(40), short: 'fffffff', date: '2026-09-22T10:00:00Z', subject: 'denied deploy' };const sends = [{ op: 'db', ref: REF - 50, key: 'live:denied:1', args: ['saveChatMessage', { stream_id: streamId, username: 'Bot', message: 'denied insert', message_type: 'chat' }] },@@@ -74,9 +74,9 @@ t('sending a chat message needs chat.message.send; without it only those ops areconst denied = (m) => /denied (insert|line|dm|deploy)/.test(JSON.stringify(m));assert.ok(await viewerWs.none(denied), 'no refused message reached the stream room');assert.ok(await globalWs.none(denied), 'no refused message reached global chat');-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n, before, 'nothing persisted');-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM events_outbox').n, outboxBefore, 'no chat.message.created');-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM bridge_applied WHERE key = ?', ['live:denied:1']).n, 0, 'a refused write is not marked applied');++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n, before, 'nothing persisted');++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM events_outbox')).n, outboxBefore, 'no chat.message.created');++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM bridge_applied WHERE key = ?', ['live:denied:1'])).n, 0, 'a refused write is not marked applied');// The capability alone is not enough either: the bridge capability is still required.assert.strictEqual((await calls(sends.slice(0, 1), { token: h.serviceToken(['chat.message.send']) })).status, 403);// An app principal holding both may send.@@@ -101,16 +101,16 @@ t('a forwarded insert gets its real id, and later ops of the boot are rewritten'const cross = await globalWs.next((m) => m.type === 'chat' && m.message === 'beep boop');assert.strictEqual(cross.id, realId);assert.strictEqual(cross.stream_channel, 'streamer');-- const row = h.db.getChatMessageById(realId);++ const row = await h.db.getChatMessageById(realId);assert.strictEqual(row.channel_user_id, streamer.id, 'channel derived from the stream');assert.strictEqual(row.source_platform, 'ai');});t('a retried write (same key) is applied once and answers the first result', async () => {-- const before = h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n;++ const before = (await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n;const r = await calls([{ op: 'db', ref: REF, key: 'live:1', args: ['saveChatMessage', { stream_id: streamId, username: 'ChatBot', message: 'beep boop', message_type: 'chat' }] }]);assert.strictEqual(r.body.results[0].result.lastInsertRowid, realId);-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n, before);++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n, before);});t('placeholders are per Live boot', async () => {@@@ -127,12 +127,12 @@ t('only allow-listed writes; unknown ops refused', async () => {{ op: 'db', args: ['deleteChatMessage', realId, streamer.id] },]);assert.deepStrictEqual(r.body.results.map((x) => x.ok), [false, false, true]);-- assert.ok(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n > 0);-- assert.strictEqual(h.db.getChatMessageById(realId).is_deleted, 1);++ assert.ok((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n > 0);++ assert.strictEqual((await h.db.getChatMessageById(realId)).is_deleted, 1);// Moderation from Live's /api/mod lands in Chat's log, with an internal event.const m = await calls([{ op: 'db', ref: REF - 1, key: 'live:2', args: ['logModerationAction', { scope_type: 'site', actor_user_id: streamer.id, action_type: 'message_delete', details: { id: realId } }] }]);assert.ok(m.body.results[0].ok);-- const ev = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'").map((x) => JSON.parse(x.event)).pop();++ const ev = (await h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'")).map((x) => JSON.parse(x.event)).pop();assert.strictEqual(ev.payload.action_type, 'message_delete');assert.strictEqual(ev.visibility, 'internal');});@@@ -147,11 +147,11 @@ t('DMs through the bridge keep the participant check', async () => {});t('a user update from Live refreshes sockets and rewrites stored names', async () => {-- h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: 'Viewer', message: 'old name line', message_type: 'chat' });++ await h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: 'Viewer', message: 'old name line', message_type: 'chat' });await calls([{ op: 'sendUserUpdate', args: [viewer.id, { id: viewer.id, username: 'viewer', display_name: 'Viewer Renamed', role: 'user', avatar_url: null, profile_color: '#123456' }] }]);const u = await viewerWs.next((m) => m.type === 'user-updated');assert.strictEqual(u.user.display_name, 'Viewer Renamed');-- assert.strictEqual(h.db.get('SELECT username FROM chat_messages WHERE message = ?', ['old name line']).username, 'Viewer Renamed');++ assert.strictEqual((await h.db.get('SELECT username FROM chat_messages WHERE message = ?', ['old name line'])).username, 'Viewer Renamed');await h.sleep(1100);viewerWs.sendJson({ type: 'chat', message: 'with the new name' });const own = await globalWs.next((m) => m.type === 'chat' && m.message === 'with the new name');@@@ -168,20 +168,20 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded oconst commit = (n) => ({ hash: String(n).repeat(40).slice(0, 40), short: String(n).repeat(7), date: '2026-09-22T10:00:00Z', subject: `change ${n}` });let r = await calls([{ op: 'deployNotice', args: [[commit(1)]] }]);const id1 = r.body.results[0].result.id;-- const row = h.db.getChatMessageById(id1);++ const row = await h.db.getChatMessageById(id1);assert.strictEqual(row.message_type, 'system');assert.strictEqual(JSON.parse(row.metadata).kind, 'deploy');r = await calls([{ op: 'deployNotice', args: [[commit(2)]] }]);assert.strictEqual(r.body.results[0].result.id, id1, 'folded into the same row');-- assert.strictEqual(JSON.parse(h.db.getChatMessageById(id1).metadata).deploys, 2);++ assert.strictEqual(JSON.parse((await h.db.getChatMessageById(id1)).metadata).deploys, 2);// Someone speaks in a stream room (the global feed shows it): the next deploy is a new card,// so no card's time range runs past a message under it.-- h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: 'hello brother', message_type: 'chat', is_global: false });++ await h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: 'hello brother', message_type: 'chat', is_global: false });r = await calls([{ op: 'deployNotice', args: [[commit(3)]] }]);const id3 = r.body.results[0].result.id;assert.notStrictEqual(id3, id1, 'a message since the last card starts a new card');-- assert.strictEqual(JSON.parse(h.db.getChatMessageById(id3).metadata).deploys, 1);-- assert.strictEqual(JSON.parse(h.db.getChatMessageById(id1).metadata).deploys, 2, 'the old card is left as it was');++ assert.strictEqual(JSON.parse((await h.db.getChatMessageById(id3)).metadata).deploys, 1);++ assert.strictEqual(JSON.parse((await h.db.getChatMessageById(id1)).metadata).deploys, 2, 'the old card is left as it was');// A late joiner gets it once.const late = await h.ws({ ip: '198.51.100.52' });late.sendJson({ type: 'join' });@@@ -191,11 +191,11 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded o});t('the invalidate op is accepted for a channel and refuses an unknown kind', async () => {-- assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), false);-- h.db.addChannelModerator(channelId, viewer.id, streamer.id); // the moderator row is Chat's own (C-04)++ assert.strictEqual(await h.ctx.isChannelModerator(viewer.id, channelId), false);++ await h.db.addChannelModerator(channelId, viewer.id, streamer.id); // the moderator row is Chat's own (C-04)const r = await calls([{ op: 'invalidate', args: ['channel', channelId] }, { op: 'invalidate', args: ['nonsense', 1] }]);assert.deepStrictEqual(r.body.results.map((x) => x.ok), [true, false]);-- assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), true);++ assert.strictEqual(await h.ctx.isChannelModerator(viewer.id, channelId), true);});t('presence snapshot for Live’s synchronous reads', async () => {@@@ -59,7 +59,7 @@ async function join(opts) {}const bye = async (...sockets) => { for (const ws of sockets) { if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); await ws.closed; } } await h.sleep(30); };const api = (method, path, u, body) => h.http(method, `/api/streams${path}`, { token: u && u.token, body });--const rows = (where = {}) => lifecycle.list(where);++const rows = async (where = {}) => await lifecycle.list(where);async function until(fn, ms = 2000) {const end = Date.now() + ms;for (;;) {@@@ -86,7 +86,7 @@ t('boot (CHAT_CALLS=1, a short ring timeout)', async () => {const ch = h.addChannel(streamer.id);liveStream = h.addStream(streamer.id, ch, { title: 'Late show' });offStream = h.addStream(streamer.id, ch, { title: 'Yesterday', is_live: 0 });-- for (const u of [ann, bob, cat, dan, eve, kay, staff, streamer]) h.ctx.upsertUser(h.live.users.get(u.id));++ for (const u of [ann, bob, cat, dan, eve, kay, staff, streamer]) await h.ctx.upsertUser(h.live.users.get(u.id));await h.ctx.sync();assert.strictEqual(config.calls.enabled, true);});@@@ -136,13 +136,13 @@ t('public lobby: join, peer-joined, participant info, the list pushed to chat soconst me = anon.welcome.participants.find((x) => x.peerId === anon.welcome.peerId);assert.ok(/^anon\d+$/.test(me.anonId) && me.userId === null && me.displayName === me.anonId, 'signed out = anonymous, as on Live');-- let session = rows({ channelId: 'public' })[0];++ let session = (await rows({ channelId: 'public' }))[0];assert.deepStrictEqual([session.kind, session.state, session.end_reason], ['channel', 'active', null]);await bye(b);const left = await a.next((m) => m.type === 'peer-left');assert.deepStrictEqual([left.peerId, left.reason, left.username], [b.welcome.peerId, 'disconnect', 'bob']);await bye(a, anon, chat);-- session = lifecycle.get(session.id);++ session = await lifecycle.get(session.id);assert.deepStrictEqual([session.state, session.end_reason], ['ended', 'empty'], 'the session ends when the channel empties');assert.ok(session.started_at && session.ended_at >= session.started_at);assert.strictEqual(callServer.getParticipantCount('public'), 0);@@@ -269,7 +269,7 @@ t('ban and unban', async () => {await back.next((m) => m.type === 'call-ended');await k.next((m) => m.type === 'call-ended');assert.ok(!callServer.channels.has(kayChannel));-- const session = rows({ channelId: kayChannel })[0];++ const session = (await rows({ channelId: kayChannel }))[0];assert.deepStrictEqual([session.kind, session.state, session.end_reason], ['channel', 'ended', 'ended']);await bye(back, k);});@@@ -307,7 +307,7 @@ t('call-user: ringing → accepted → active → ended; private call; Live askeannCall = r.body.channel.id;const invite = await catChat.next((m) => m.type === 'vc-call-invite');assert.deepStrictEqual([invite.channelId, invite.channelName, invite.fromUserId, invite.fromUsername, invite.fromDisplayName], [annCall, 'Ann\'s call', ann.id, 'ann', 'Ann']);-- let ring = rows({ kind: 'direct' })[0];++ let ring = (await rows({ kind: 'direct' }))[0];assert.deepStrictEqual([ring.state, ring.created_by, ring.target_user_id, ring.channel_id, ring.created_by_subject, ring.target_subject], ['ringing', ann.id, cat.id, annCall, ann.subject_id, cat.subject_id]);const note = await until(() => h.live.effects.find((e) => e.name === 'notify/call-invite'));assert.deepStrictEqual(note.body, { caller_id: ann.id, target_id: cat.id, channel_id: annCall, channel_name: 'Ann\'s call' });@@@ -330,15 +330,15 @@ t('call-user: ringing → accepted → active → ended; private call; Live askeassert.deepStrictEqual(r.body, { ok: true });const answer = await annChat.next((m) => m.type === 'vc-call-response');assert.deepStrictEqual([answer.status, answer.channelId, answer.fromUserId, answer.fromUsername], ['accepted', annCall, cat.id, 'cat']);-- ring = lifecycle.get(ring.id);++ ring = await lifecycle.get(ring.id);assert.deepStrictEqual([ring.state, ring.end_reason], ['active', null]);assert.ok(ring.answered_at >= ring.started_at);const c = await join({ channelId: annCall, token: cat.token, ip: '198.51.100.43' });assert.ok(c.welcome, c.error);await h.sleep(1400);-- assert.strictEqual(lifecycle.get(ring.id).state, 'active', 'an answered call is not missed');++ assert.strictEqual((await lifecycle.get(ring.id)).state, 'active', 'an answered call is not missed');await bye(a, c, annChat, catChat, anon, outsider);-- ring = lifecycle.get(ring.id);++ ring = await lifecycle.get(ring.id);assert.deepStrictEqual([ring.state, ring.end_reason], ['ended', 'empty']);});@@@ -350,27 +350,27 @@ t('call-user: declined, busy, missed after the ring timeout (the caller hears "nassert.deepStrictEqual([r.status, r.body.reusedChannel, r.body.channel.id], [200, true, annCall], 'the caller\'s channel is reused');await api('POST', '/voice-channels/call-user/respond', dan, { caller_user_id: ann.id, channel_id: annCall, status: 'declined' });assert.strictEqual((await annChat.next((m) => m.type === 'vc-call-response')).status, 'declined');-- const declined = rows({ kind: 'direct' })[0];++ const declined = (await rows({ kind: 'direct' }))[0];assert.deepStrictEqual([declined.target_user_id, declined.state, declined.end_reason], [dan.id, 'declined', 'declined']);assert.ok(declined.ended_at);// busyawait api('POST', '/voice-channels/call-user', ann, { username: 'eve' });await api('POST', '/voice-channels/call-user/respond', eve, { caller_user_id: ann.id, channel_id: annCall, status: 'busy' });-- assert.deepStrictEqual([rows({ kind: 'direct' })[0].state, rows({ kind: 'direct' })[0].end_reason], ['declined', 'busy']);++ assert.deepStrictEqual([(await rows({ kind: 'direct' }))[0].state, (await rows({ kind: 'direct' }))[0].end_reason], ['declined', 'busy']);// missed: bob never answersr = await api('POST', '/voice-channels/call-user', ann, { username: 'bob' });-- const ringing = rows({ kind: 'direct' })[0];++ const ringing = (await rows({ kind: 'direct' }))[0];assert.strictEqual(ringing.state, 'ringing');// Ringing again re-rings the same call.await api('POST', '/voice-channels/call-user', ann, { username: 'bob' });-- assert.strictEqual(rows({ kind: 'direct' })[0].id, ringing.id);++ assert.strictEqual((await rows({ kind: 'direct' }))[0].id, ringing.id);const noAnswer = await annChat.next((m) => m.type === 'vc-call-response' && m.status === 'no-answer', 4000);assert.deepStrictEqual([noAnswer.fromUserId, noAnswer.fromUsername, noAnswer.channelId], [bob.id, 'bob', annCall]);-- const missed = lifecycle.get(ringing.id);++ const missed = await lifecycle.get(ringing.id);assert.deepStrictEqual([missed.state, missed.end_reason], ['missed', 'timeout']);// bob's late "accepted" finds no ring to answer (the relay still goes, as on Live)assert.strictEqual((await api('POST', '/voice-channels/call-user/respond', bob, { caller_user_id: ann.id, channel_id: annCall, status: 'accepted' })).status, 200);-- assert.strictEqual(lifecycle.get(ringing.id).state, 'missed');++ assert.strictEqual((await lifecycle.get(ringing.id)).state, 'missed');// Six rings a minute (ann has used five).await api('POST', '/voice-channels/call-user', ann, { username: 'kay' });r = await api('POST', '/voice-channels/call-user', ann, { username: 'kay' });@@@ -387,13 +387,13 @@ t('call-user: an invite that cannot be delivered is failed, with the reason', aslet r;try { r = await api('POST', '/voice-channels/call-user', eve, { username: 'dan' }); } finally { h.chatServer.sendDm = orig; }assert.deepStrictEqual([r.status, r.body.error], [500, 'Failed to call user']);-- const row = rows({ kind: 'direct' })[0];++ const row = (await rows({ kind: 'direct' }))[0];assert.deepStrictEqual([row.created_by, row.state, row.end_reason], [eve.id, 'failed', 'invite_failed: socket layer down']);// Deleting a channel ends the rings still waiting on it (canceled).r = await api('POST', '/voice-channels/call-user', eve, { username: 'dan' });-- assert.strictEqual(rows({ kind: 'direct' })[0].state, 'ringing');++ assert.strictEqual((await rows({ kind: 'direct' }))[0].state, 'ringing');await api('DELETE', `/voice-channels/${r.body.channel.id}`, eve);-- assert.deepStrictEqual([rows({ kind: 'direct' })[0].state, rows({ kind: 'direct' })[0].end_reason], ['ended', 'canceled']);++ assert.deepStrictEqual([(await rows({ kind: 'direct' }))[0].state, (await rows({ kind: 'direct' }))[0].end_reason], ['ended', 'canceled']);});t('stream channel through /internal/calls: guard, create, join (legacy id), mode change, removal', async () => {@@@ -420,7 +420,7 @@ t('stream channel through /internal/calls: guard, create, join (legacy id), modeassert.strictEqual(host.welcome.participants.find((p) => p.username === 'streamy').isStreamer, true);r = await api('GET', `/${liveStream}/call`, null);assert.strictEqual(r.body.participant_count, 2);-- let session = rows({ channelId: `stream-${liveStream}` })[0];++ let session = (await rows({ channelId: `stream-${liveStream}` }))[0];assert.deepStrictEqual([session.kind, session.state, session.stream_id, session.created_by], ['stream', 'active', liveStream, streamer.id]);// A new mode ends the call in progress (Live's createStreamChannel).@@@ -428,16 +428,16 @@ t('stream channel through /internal/calls: guard, create, join (legacy id), modeassert.strictEqual(r.body.channel.mode, 'cam+mic');await viewer.next((m) => m.type === 'call-ended');await host.next((m) => m.type === 'call-ended');-- assert.deepStrictEqual([lifecycle.get(session.id).state, lifecycle.get(session.id).end_reason], ['ended', 'ended']);++ assert.deepStrictEqual([(await lifecycle.get(session.id)).state, (await lifecycle.get(session.id)).end_reason], ['ended', 'ended']);await bye(viewer, host);const v2 = await join({ channelId: `stream-${liveStream}`, token: bob.token, ip: '198.51.100.50' });assert.strictEqual(v2.welcome.callMode, 'cam+mic');-- session = rows({ channelId: `stream-${liveStream}` })[0];++ session = (await rows({ channelId: `stream-${liveStream}` }))[0];r = await h.http('DELETE', `/internal/calls/stream-channel/${liveStream}`, { token });assert.deepStrictEqual(r.body, { ok: true, removed: true });await v2.next((m) => m.type === 'call-ended');-- assert.deepStrictEqual([lifecycle.get(session.id).state, lifecycle.get(session.id).end_reason], ['ended', 'stream_ended']);++ assert.deepStrictEqual([(await lifecycle.get(session.id)).state, (await lifecycle.get(session.id)).end_reason], ['ended', 'stream_ended']);assert.deepStrictEqual((await api('GET', `/${liveStream}/call`, null)).body, { call_mode: null, channelId: null, participants: [], participant_count: 0 });assert.deepStrictEqual((await h.http('DELETE', `/internal/calls/stream-channel/${liveStream}`, { token })).body, { ok: true, removed: false });const gone = await join({ channelId: `stream-${liveStream}`, token: bob.token, ip: '198.51.100.50' });@@@ -467,29 +467,29 @@ t('PUT /api/streams/:id/call: the streamer only, a live stream only, a known mod});t('lifecycle: transitions are one-way; a restart closes what was left open', async () => {-- const d = lifecycle.openDirect({ callerId: ann.id, targetId: kay.id, channelId: 'user-x' });++ const d = await lifecycle.openDirect({ callerId: ann.id, targetId: kay.id, channelId: 'user-x' });assert.strictEqual(d.state, 'pending');-- assert.strictEqual(lifecycle.transition(d.id, 'active'), null, 'pending cannot become active without ringing');-- assert.strictEqual(lifecycle.transition(d.id, 'declined'), null);-- lifecycle.ring(d.id, { timeoutMs: 60000 });-- const declined = lifecycle.respond({ callerId: ann.id, targetId: kay.id, channelId: 'user-x', status: 'declined' });++ assert.strictEqual(await lifecycle.transition(d.id, 'active'), null, 'pending cannot become active without ringing');++ assert.strictEqual(await lifecycle.transition(d.id, 'declined'), null);++ await lifecycle.ring(d.id, { timeoutMs: 60000 });++ const declined = await lifecycle.respond({ callerId: ann.id, targetId: kay.id, channelId: 'user-x', status: 'declined' });assert.strictEqual(declined.state, 'declined');-- assert.strictEqual(lifecycle.transition(d.id, 'active'), null, 'a final state stays final');-- assert.strictEqual(lifecycle.respond({ callerId: ann.id, targetId: kay.id, channelId: 'user-x', status: 'accepted' }), null);---- const pending = lifecycle.openDirect({ callerId: bob.id, targetId: kay.id, channelId: 'user-y' });-- const ringing = lifecycle.ring(lifecycle.openDirect({ callerId: cat.id, targetId: kay.id, channelId: 'user-z' }).id, { timeoutMs: 60000 });-- const active = lifecycle.sessionStarted({ channelId: 'user-z' });-- assert.strictEqual(lifecycle.sessionStarted({ channelId: 'user-z' }).id, active.id, 'one session per channel');-- const out = lifecycle.recover();++ assert.strictEqual(await lifecycle.transition(d.id, 'active'), null, 'a final state stays final');++ assert.strictEqual(await lifecycle.respond({ callerId: ann.id, targetId: kay.id, channelId: 'user-x', status: 'accepted' }), null);++++ const pending = await lifecycle.openDirect({ callerId: bob.id, targetId: kay.id, channelId: 'user-y' });++ const ringing = await lifecycle.ring((await lifecycle.openDirect({ callerId: cat.id, targetId: kay.id, channelId: 'user-z' })).id, { timeoutMs: 60000 });++ const active = await lifecycle.sessionStarted({ channelId: 'user-z' });++ assert.strictEqual((await lifecycle.sessionStarted({ channelId: 'user-z' })).id, active.id, 'one session per channel');++ const out = await lifecycle.recover();assert.ok(out.failed >= 1 && out.missed >= 1 && out.ended >= 1, JSON.stringify(out));-- assert.deepStrictEqual([lifecycle.get(pending.id).state, lifecycle.get(pending.id).end_reason], ['failed', 'restart']);-- assert.deepStrictEqual([lifecycle.get(ringing.id).state, lifecycle.get(ringing.id).end_reason], ['missed', 'restart']);-- assert.deepStrictEqual([lifecycle.get(active.id).state, lifecycle.get(active.id).end_reason], ['ended', 'restart']);-- assert.strictEqual(h.db.get("SELECT COUNT(*) AS n FROM calls WHERE state IN ('pending', 'ringing', 'active')").n, 0);++ assert.deepStrictEqual([(await lifecycle.get(pending.id)).state, (await lifecycle.get(pending.id)).end_reason], ['failed', 'restart']);++ assert.deepStrictEqual([(await lifecycle.get(ringing.id)).state, (await lifecycle.get(ringing.id)).end_reason], ['missed', 'restart']);++ assert.deepStrictEqual([(await lifecycle.get(active.id)).state, (await lifecycle.get(active.id)).end_reason], ['ended', 'restart']);++ assert.strictEqual((await h.db.get("SELECT COUNT(*) AS n FROM calls WHERE state IN ('pending', 'ringing', 'active')")).n, 0);});t.run(async () => {-- try { if (callServer) callServer.close(); } catch { /* */ }++ try { if (callServer) await callServer.close(); } catch { /* */ }if (h && h.close) await h.close();});@@@ -12,14 +12,14 @@ const { boot, suite, sqliteNow } = require('./helpers');const t = suite('chat-ai');let h, db, ctx, chatAi, aiClient, streamer, alice, bob, channelId, streamId;--function msg(o = {}) {-- return db.run(++async function msg(o = {}) {++ return (await db.run(`INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, channel_user_id, timestamp)VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,[o.stream_id === undefined ? streamId : o.stream_id, o.user_id === undefined ? null : o.user_id,o.username || null, o.message || 'a message', o.message_type || 'chat', o.is_global ? 1 : 0,o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow()]-- ).lastInsertRowid;++ )).lastInsertRowid;}t('boot', async () => {@@@ -50,8 +50,8 @@ t('the job selects Chat messages and writes the summary', async () => {},},});-- msg({ user_id: alice.id, username: 'alice', message: 'hello world' });-- msg({ user_id: bob.id, username: 'bob', message: 'a big donation', message_type: 'donation' });++ await msg({ user_id: alice.id, username: 'alice', message: 'hello world' });++ await msg({ user_id: bob.id, username: 'bob', message: 'a big donation', message_type: 'donation' });await chatAi._tick();@@@ -60,23 +60,23 @@ t('the job selects Chat messages and writes the summary', async () => {assert.strictEqual(global.input.messages.length, 2);assert.strictEqual(global.input.messages[0].author, 'alice');-- const row = db.getChatAiSummary('global', 0, 'global');++ const row = await db.getChatAiSummary('global', 0, 'global');assert.ok(row, 'a global summary row was written');assert.strictEqual(row.overview, 'the room talked about games');assert.strictEqual(row.memory_json, 'memory line');assert.ok(JSON.parse(row.timeline_json).some((x) => x.label === 'a moment'));-- const events = db.getChatTimelineEvents({ scope: 'global', subjectId: 0, limit: 10 });++ const events = await db.getChatTimelineEvents({ scope: 'global', subjectId: 0, limit: 10 });assert.ok(events.some((e) => e.label === 'a moment'), 'the timeline event was appended');});t('falls back to the extractive summary when AI does not answer', async () => {aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed' } }) } });-- db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-40 * 60 * 1000)]);-- msg({ user_id: alice.id, username: 'alice', message: 'the extractive line' });++ await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-40 * 60 * 1000)]);++ await msg({ user_id: alice.id, username: 'alice', message: 'the extractive line' });await chatAi._tick();-- const row = db.getChatAiSummary('global', 0, 'global');++ const row = await db.getChatAiSummary('global', 0, 'global');assert.ok(/messages from \d+ chatter/.test(row.overview), row.overview);assert.ok(row.overview.includes('the extractive line'), row.overview);aiClient._reset();@@@ -86,32 +86,32 @@ t('falls back to the extractive summary when AI does not answer', async () => {t('the job is off unless CHAT_AI_ENABLED is set', async () => {const cfg = require('../server/config');assert.strictEqual(cfg.ai.enabled, true, 'this boot enabled it');-- const before = db.getChatAiSummary('global', 0, 'global').message_count;++ const before = (await db.getChatAiSummary('global', 0, 'global')).message_count;cfg.ai.enabled = false;-- msg({ user_id: bob.id, username: 'bob', message: 'while the job is off' });++ await msg({ user_id: bob.id, username: 'bob', message: 'while the job is off' });await chatAi._tick();-- assert.strictEqual(db.getChatAiSummary('global', 0, 'global').message_count, before);++ assert.strictEqual((await db.getChatAiSummary('global', 0, 'global')).message_count, before);cfg.ai.enabled = true;});t('the six routes answer with Live\'s shapes, publicly', async () => {-- db.upsertChatAiSummary({++ await db.upsertChatAiSummary({scope: 'global', subject_id: 0, window: 'global', overview: 'everything happened', memory_json: 'mem',timeline_json: JSON.stringify([{ ts: '2026-01-01 00:00:00', label: 'moment', detail: 'detail' }]),message_count: 5, window_message_count: 4, window_label: 'past hour',});-- db.upsertChatAiSummary({++ await db.upsertChatAiSummary({scope: 'user', subject_id: alice.id, window: 'rolling',overview: JSON.stringify({ today: 'today read', alltime: 'all time read', has_24h: true }),timeline_json: '[]', message_count: 9,});-- db.upsertChatAiSummary({++ await db.upsertChatAiSummary({scope: 'anon', subject_id: 7, window: 'rolling',overview: JSON.stringify({ today: 'anon today', alltime: 'anon all', has_24h: false }), timeline_json: '[]', message_count: 2,});-- db.recordRelayUser('twitch', 'SomeOne');-- const ru = db.getRelayUser('twitch', 'someone');-- db.upsertChatAiSummary({++ await db.recordRelayUser('twitch', 'SomeOne');++ const ru = await db.getRelayUser('twitch', 'someone');++ await db.upsertChatAiSummary({scope: 'relay', subject_id: ru.id, window: 'rolling',overview: JSON.stringify({ today: 'relay today', alltime: 'relay all', has_24h: true }), timeline_json: '[]', message_count: 3,});@@@ -70,10 +70,10 @@ t('internal read API: settings + moderator_ids validate, defaults for a channel});t('internal read API: moderator_ids oldest first; the channels a user moderates validate', async () => {-- h.db.addChannelModerator(channelId, mod.id, streamer.id);-- h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);-- h.db.addChannelModerator(channelId, stranger.id, streamer.id);-- h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-02-01 00:00:00', stranger.id]);++ await h.db.addChannelModerator(channelId, mod.id, streamer.id);++ await h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);++ await h.db.addChannelModerator(channelId, stranger.id, streamer.id);++ await h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-02-01 00:00:00', stranger.id]);const r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: RO });checkContract('chat.channel-moderation-result', r.body);assert.deepStrictEqual(r.body.moderator_ids, [mod.id, stranger.id]);@@@ -118,7 +118,7 @@ t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap',assert.strictEqual(up.status, 200, JSON.stringify(up.body));assert.strictEqual(up.body.emote.code, 'hype');assert.strictEqual(up.body.emote.url, 'https://openvibe.media/chat/emote.png');-- const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);++ const row = await h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);assert.strictEqual(row.media_asset_id, 777);assert.strictEqual(row.media_url, 'https://openvibe.media/chat/emote.png');assert.strictEqual(row.size, 150);@@@ -155,19 +155,19 @@ t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap',});t('emote API: rename/resize, delete removes the Media object', async () => {-- const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);++ const row = await h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);let r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: streamer.token, body: { code: 'hype2', size: 300 } });assert.strictEqual(r.status, 200, r.text);assert.deepStrictEqual([r.body.code, r.body.size], ['hype2', 200], 'size clamped to the channel max (200)');r = await h.http('DELETE', `/api/emotes/${row.id}`, { token: streamer.token });assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes WHERE id = ?', [row.id]).n, 0);++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM emotes WHERE id = ?', [row.id])).n, 0);assert.deepStrictEqual(deletedAssets, [777], 'the Media object is removed');});t('emote API: not-mine is refused, sources round-trip through the Live-context effect', async () => {-- const row = h.db.get('SELECT id FROM emotes WHERE code = ?', ['two']);++ const row = await h.db.get('SELECT id FROM emotes WHERE code = ?', ['two']);const r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: other.token, body: { code: 'nope' } });assert.strictEqual(r.status, 403, r.text);@@@ -212,7 +212,7 @@ t('moderators: list is public to any signed-in reader; moderation settings are m});t('moderation settings: owner writes, a mod cannot change policy keys', async () => {-- h.db.addChannelModerator(channelId, mod.id, streamer.id);++ await h.db.addChannelModerator(channelId, mod.id, streamer.id);let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });assert.strictEqual(r.status, 200, r.text);assert.strictEqual(r.body.settings.slow_mode_seconds, 12);@@@ -239,7 +239,7 @@ t('moderation settings: owner writes, a mod cannot change policy keys', async ()t('alert op: Chat resolves the sound from its own row and broadcasts it', async () => {const snd = path.join(h.tmp, 'sounds', 'donation.mp3');fs.writeFileSync(snd, 'ID3alert');-- h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');++ await h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');const ws = await h.ws({ ip: '198.51.100.7', stream: streamId });ws.sendJson({ type: 'join', streamId, channelUserId: streamer.id, anonId: null });@@@ -21,15 +21,15 @@ const PRIVATE = [/\/messages/, /\/settings/, /\/auth\//, /\/api\//, /\/internal\t('boot', async () => {h = await boot({ env: { CHAT_WEB_URL: SITE } });alice = h.addUser('alice', { subject: ids.newId('user') });-- h.ctx.upsertUser(h.live.users.get(alice.id));++ await h.ctx.upsertUser(h.live.users.get(alice.id));const rooms = require('../server/rooms/rooms');-- rooms.create(alice, { name: 'Night Owls', topic: 'Late chats' });-- rooms.create(alice, { name: 'Closed Room', slug: 'private-room', visibility: 'private' });++ await rooms.create(alice, { name: 'Night Owls', topic: 'Late chats' });++ await rooms.create(alice, { name: 'Closed Room', slug: 'private-room', visibility: 'private' });await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'hello from the test' } });// Backdate the message so "lastmod is the data's own day" is checkable against "not today".const db = require('../server/db/database');-- db.run("UPDATE chat_messages SET timestamp = '2026-01-02 03:04:05' WHERE id = (SELECT MAX(id) FROM chat_messages)");-- h.db.run("UPDATE rooms SET last_message_at = '2026-02-03 04:05:06' WHERE slug = 'night-owls'");++ await db.run("UPDATE chat_messages SET timestamp = '2026-01-02 03:04:05' WHERE id = (SELECT MAX(id) FROM chat_messages)");++ await h.db.run("UPDATE rooms SET last_message_at = '2026-02-03 04:05:06' WHERE slug = 'night-owls'");require('../server/chat/history-store').invalidate('global');});@@@ -31,7 +31,7 @@ t('boot', async () => {alice = h.addUser('alice', { subject: ids.newId('user') });bob = h.addUser('bob', { subject: ids.newId('user') });carol = h.addUser('carol', { subject: ids.newId('user') });-- for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }++ for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }});t('the inbox: your conversations only, unread counts in the list and the navigation', async () => {@@@ -75,7 +75,7 @@ t('participants only: a guessed conversation looks like no conversation', asyncassert.strictEqual((await dmApi('GET', `/conversations/${conv}`, carol)).status, 403);const r = await req('POST', `/messages/${conv}`, form(carol, { message: 'let me in' }));assert.match(r.headers.get('location'), /error=/);-- assert.ok(!h.db.all('SELECT message FROM dm_messages WHERE conversation_id = ?', [conv]).some((m) => m.message === 'let me in'));++ assert.ok(!(await h.db.all('SELECT message FROM dm_messages WHERE conversation_id = ?', [conv])).some((m) => m.message === 'let me in'));assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(carol, {}))).headers.get('location'), '/messages', 'nor block from it');});@@@ -122,7 +122,7 @@ t('groups have no one to block', async () => {const page = await req('GET', `/messages/${gid}`, { headers: cookie(alice) });assert.ok(!/Block /.test(page.text));assert.match((await req('POST', `/messages/${gid}/block`, form(alice, {}))).headers.get('location'), new RegExp(`^/messages/${gid}\\?error=`));-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM dm_blocks WHERE blocker_id = ?', [alice.id]).n, 0);++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM dm_blocks WHERE blocker_id = ?', [alice.id])).n, 0);});t.run(async () => { if (h && h.close) await h.close(); });@@@ -105,10 +105,10 @@ function deliver(event, { secret = SECRET, now = Date.now(), headers = {}, stripreturn h.http('POST', '/internal/events', { raw, headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Event-Id': event.event_id, ...signed, ...headers } });}const bridge = (commits) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'live-boot-1', ops: [{ seq: ++n, op: 'deployNotice', args: [commits] }] } });--const cards = () => h.db.all("SELECT id, metadata FROM chat_messages WHERE message_type = 'system' AND metadata LIKE '%\"kind\":\"deploy\"%' ORDER BY id").map((r) => ({ id: r.id, ...JSON.parse(r.metadata) }));--const card = (id) => cards().find((c) => c.id === id);--const speak = (text) => h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: text, message_type: 'chat', is_global: false });--const releaseRow = (head) => h.db.get('SELECT * FROM deploy_releases WHERE head = ?', [head]);++const cards = async () => (await h.db.all("SELECT id, metadata FROM chat_messages WHERE message_type = 'system' AND metadata LIKE '%\"kind\":\"deploy\"%' ORDER BY id")).map((r) => ({ id: r.id, ...JSON.parse(r.metadata) }));++const card = async (id) => (await cards()).find((c) => c.id === id);++const speak = async (text) => await h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: text, message_type: 'chat', is_global: false });++const releaseRow = async (head) => await h.db.get('SELECT * FROM deploy_releases WHERE head = ?', [head]);t('boot with the consumer on and a stub Events', async () => {const eventsPort = await new Promise((r) => eventsServer.listen(0, '127.0.0.1', () => r(eventsServer.address().port)));@@@ -168,8 +168,8 @@ t('signature v2 only: bad signature, stale or future timestamp, v1-only, unsigne// A v2 header that names another time than X-OpenVibe-Timestamp.r = await deliver(ev, { headers: { 'X-OpenVibe-Timestamp': String(Math.floor(Date.now() / 1000) - 5) } });assert.strictEqual(r.status, 401);-- assert.strictEqual(cards().length, 0, 'nothing was applied');-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n, 0);++ assert.strictEqual((await cards()).length, 0, 'nothing was applied');++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n, 0);// Signed but not an envelope.r = await deliver({ event_type: 'live.release.deployed' });assert.strictEqual(r.status, 400);@@@ -198,27 +198,27 @@ t('no CHAT_EVENTS_SECRET: the route answers 503', async () => {});t('live.release.deployed makes the deploy card; the same event again is one card (inbox)', async () => {-- speak('before the first card');++ await speak('before the first card');const ev = release([commit('a', 'events: first'), commit('b', 'events: second')]);let r = await deliver(ev);assert.strictEqual(r.status, 200, r.text);assert.deepStrictEqual([r.body.duplicate, r.body.outcome], [false, 'announced']);const id = r.body.detail.message_id;-- const c = card(id);++ const c = await card(id);assert.deepStrictEqual(c.commits.map((x) => x.subject), ['events: first', 'events: second']);assert.strictEqual(c.deploys, 1);-- const row = h.db.getChatMessageById(id);++ const row = await h.db.getChatMessageById(id);assert.strictEqual(row.message_type, 'system');assert.strictEqual(row.is_global, 1);assert.strictEqual(row.username, 'OpenVibe.Live');assert.strictEqual(row.message, '🚀 2 updates shipped: events: first · events: second');-- const before = cards().length;++ const before = (await cards()).length;r = await deliver(ev); // Events redelivers (a lost acknowledgement)assert.deepStrictEqual([r.status, r.body.duplicate, r.body.outcome], [200, true, null]);r = await deliver(ev, { now: Date.now() + 1000 }); // a later attempt, new signatureassert.strictEqual(r.body.duplicate, true);-- assert.strictEqual(cards().length, before, 'still one card');-- assert.strictEqual(card(id).deploys, 1, 'not folded into itself');++ assert.strictEqual((await cards()).length, before, 'still one card');++ assert.strictEqual((await card(id)).deploys, 1, 'not folded into itself');// Late joiners get this announcement once, keyed by the row id (as with the bridge).const late = await h.ws({ ip: '198.51.100.70' });late.sendJson({ type: 'join' });@@@ -226,47 +226,47 @@ t('live.release.deployed makes the deploy card; the same event again is one cardassert.strictEqual(upd.id, id);assert.deepStrictEqual(upd.fresh, [commit('a').hash, commit('b').hash]);late.close();-- const rel = releaseRow(commit('a').hash);++ const rel = await releaseRow(commit('a').hash);assert.deepStrictEqual([rel.first_via, rel.message_id, rel.event_id, rel.bridge_at], ['events', id, ev.event_id, null]);});t('bridge first, then the event for the same head: one card', async () => {-- speak('a stream message, so the next deploy is a new card');++ await speak('a stream message, so the next deploy is a new card');const commits = [commit('c', 'bridge: head'), commit('d', 'bridge: parent')];const b = await bridge(commits);const id = b.body.results[0].result.id;assert.strictEqual(b.body.results[0].result.announced, 2);-- const before = cards().length;++ const before = (await cards()).length;const r = await deliver(release(commits));assert.deepStrictEqual([r.status, r.body.duplicate, r.body.outcome], [200, false, 'duplicate:release']);assert.strictEqual(r.body.detail.message_id, id);-- assert.strictEqual(cards().length, before, 'no second card');-- assert.strictEqual(card(id).deploys, 1, 'the card was not folded again');-- const rel = releaseRow(commits[0].hash);++ assert.strictEqual((await cards()).length, before, 'no second card');++ assert.strictEqual((await card(id)).deploys, 1, 'the card was not folded again');++ const rel = await releaseRow(commits[0].hash);assert.strictEqual(rel.first_via, 'bridge');assert.ok(rel.bridge_at && rel.event_at, 'both paths are recorded as having delivered it');});t('the event first, then the bridge for the same head: one card', async () => {-- speak('another stream message');++ await speak('another stream message');const commits = [commit('e', 'event: head')];const r = await deliver(release(commits));assert.strictEqual(r.body.outcome, 'announced');const id = r.body.detail.message_id;-- const before = cards().length;++ const before = (await cards()).length;const b = await bridge(commits);assert.deepStrictEqual(b.body.results[0].result, { announced: 0, id, duplicate: true });// A retried bridge op (Live's durable queue after a lost response) is the same.const again = await bridge(commits);assert.strictEqual(again.body.results[0].result.duplicate, true);-- assert.strictEqual(cards().length, before);-- assert.strictEqual(card(id).deploys, 1);-- const rel = releaseRow(commits[0].hash);++ assert.strictEqual((await cards()).length, before);++ assert.strictEqual((await card(id)).deploys, 1);++ const rel = await releaseRow(commits[0].hash);assert.deepStrictEqual([rel.first_via, !!rel.event_at, !!rel.bridge_at], ['events', true, true]);});t('folding as before: the next deploy folds into the last card; a stream message in between starts a new card', async () => {-- speak('new card next');++ await speak('new card next');let r = await deliver(release([commit('1', 'fold: one')]));const first = r.body.detail.message_id;assert.strictEqual(r.body.outcome, 'announced');@@@ -275,23 +275,23 @@ t('folding as before: the next deploy folds into the last card; a stream messageassert.strictEqual(b.body.results[0].result.id, first, 'the bridge folds into the card the event made');r = await deliver(release([commit('3', 'fold: three')]));assert.deepStrictEqual([r.body.outcome, r.body.detail.message_id], ['folded', first]);-- assert.strictEqual(card(first).deploys, 3);-- assert.deepStrictEqual(card(first).commits.map((c) => c.subject), ['fold: three', 'fold: two', 'fold: one']);++ assert.strictEqual((await card(first)).deploys, 3);++ assert.deepStrictEqual((await card(first)).commits.map((c) => c.subject), ['fold: three', 'fold: two', 'fold: one']);r = await deliver(release([commit('2', 'fold: two')]));assert.strictEqual(r.body.outcome, 'duplicate:release', 'the bridge already announced that head');-- assert.strictEqual(card(first).deploys, 3);++ assert.strictEqual((await card(first)).deploys, 3);// Someone speaks in a stream room (the global feed shows it): the next deploy is a new card.-- speak('hello brother');++ await speak('hello brother');r = await deliver(release([commit('4', 'fold: four')]));assert.strictEqual(r.body.outcome, 'announced');const next = r.body.detail.message_id;assert.notStrictEqual(next, first);-- assert.strictEqual(card(next).deploys, 1);-- assert.strictEqual(card(first).deploys, 3, 'the old card is left as it was');++ assert.strictEqual((await card(next)).deploys, 1);++ assert.strictEqual((await card(first)).deploys, 3, 'the old card is left as it was');});t('what a release event must be: from Live, a release subject, commits, not stale', async () => {-- const before = cards().length;++ const before = (await cards()).length;const ev = release([commit('5')]);let r = await deliver({ ...ev, event_id: ids.newId('event'), source: 'media' });assert.strictEqual(r.body.outcome, 'ignored:source');@@@ -303,8 +303,8 @@ t('what a release event must be: from Live, a release subject, commits, not stalassert.strictEqual(r.body.outcome, 'ignored:stale', 'an operator replay of an old deploy says nothing');r = await deliver({ ...ev, event_id: ids.newId('event'), event_type: 'live.stream.started' });assert.strictEqual(r.body.outcome, 'ignored:type');-- assert.strictEqual(cards().length, before);-- assert.strictEqual(releaseRow(commit('6').hash), undefined);++ assert.strictEqual((await cards()).length, before);++ assert.strictEqual(await releaseRow(commit('6').hash), undefined);});t('network.module.updated drops a cached chat.preferences copy when newer; an older revision is ignored', async () => {@@@ -325,12 +325,12 @@ t('network.module.updated drops a cached chat.preferences copy when newer; an olassert.strictEqual(r.body.outcome, 'unchanged');assert.strictEqual(prefs._cached(ANN).revision, 2);// ...other namespaces and other sources are acknowledged and not recorded...-- const inbox = h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n;++ const inbox = (await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n;r = await deliver(moduleUpdated(9, { namespace: 'tools.usage' }));assert.strictEqual(r.body.outcome, 'ignored:namespace');r = await deliver(moduleUpdated(9, { source: 'live' }));assert.strictEqual(r.body.outcome, 'ignored:source');-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n, inbox);++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n, inbox);assert.strictEqual(prefs._cached(ANN).revision, 2);// ...and the newer revision drops it at once: the next read is Network's.const ev = moduleUpdated(3);@@@ -368,7 +368,7 @@ t('vip.membership.changed from VIP drops the cached badge answers at once; fromt('an event delivered with the secret the subscription holds verifies; /ready reports the consumer', async () => {const sub = stubEvents.subs.find((s) => s.topic_pattern === 'live.release.deployed');-- speak('end-to-end next');++ await speak('end-to-end next');const r = await deliver(release([commit('7', 'through the subscription')]), { secret: sub.secret, headers: { 'X-OpenVibe-Subscription-Id': sub.id } });assert.deepStrictEqual([r.status, r.body.outcome], [200, 'announced']);const ready = await h.http('GET', '/ready');@@@ -380,10 +380,10 @@ t('an event delivered with the secret the subscription holds verifies; /ready re});t('inbox receipts older than Events’ retention are pruned', async () => {-- h.db.run("INSERT INTO chat_event_inbox (consumer, event_id, processed_at) VALUES ('chat', 'evt_OLD', ?)", [Date.now() - 40 * 86400e3]);-- const kept = h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n - 1;-- assert.strictEqual(h.eventsConsumer.prune(), 1);-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n, kept);++ await h.db.run("INSERT INTO chat_event_inbox (consumer, event_id, processed_at) VALUES ('chat', 'evt_OLD', ?)", [Date.now() - 40 * 86400e3]);++ const kept = (await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n - 1;++ assert.strictEqual(await h.eventsConsumer.prune(), 1);++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n, kept);});t.run(async () => { if (h) await h.close(); eventsServer.close(); });@@@ -285,7 +285,6 @@ async function boot({ env = {} } = {}) {HOST: '127.0.0.1',BASE_URL: 'https://openvibe.live',TRUST_PROXY: '2',-- CHAT_DB_PATH: path.join(tmp, 'chat.db'),CHAT_CACHE_DIR: path.join(tmp, 'cache'),SOUNDS_PATH: path.join(tmp, 'sounds'),OV_NETWORK_URL: ISS,@@@ -319,14 +318,14 @@ async function boot({ env = {} } = {}) {return id;},/** Write the channel's own moderation settings row (the six chat tables are Chat's; C-04). */-- setChannelSettings(channelId, settings) {++ async setChannelSettings(channelId, settings) {const d = h.db.getDb();-- d.prepare('DELETE FROM channel_moderation_settings WHERE channel_id = ?').run(Number(channelId));++ await d.prepare('DELETE FROM channel_moderation_settings WHERE channel_id = ?').run(Number(channelId));if (settings) {const keys = Object.keys(settings).filter((k) => /^[a-z_]+$/.test(k));-- d.prepare(`INSERT INTO channel_moderation_settings (channel_id, ${keys.join(', ')}) VALUES (?, ${keys.map(() => '?').join(', ')})`).run(Number(channelId), ...keys.map((k) => settings[k]));++ await d.prepare(`INSERT INTO channel_moderation_settings (channel_id, ${keys.join(', ')}) VALUES (?, ${keys.map(() => '?').join(', ')})`).run(Number(channelId), ...keys.map((k) => settings[k]));}-- h.ctx.invalidateChannel(Number(channelId));++ await h.ctx.invalidateChannel(Number(channelId));},addStream(userId, channelId, { title = 'Live now', is_live = 1, managed = null } = {}) {const id = 500 + live.streams.size;@@@ -403,15 +402,19 @@ async function boot({ env = {} } = {}) {try { h.chatServer.close(); } catch { /* */ }try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); } catch { /* */ }await new Promise((r) => h.server.close(() => r()));-- try { h.db.close(); } catch { /* */ }++ try { await h.db.close(); } catch { /* */ }h.children = [];};++ /** The environment a process this test spawns needs to reach the same database (a script, a second Chat). */++ h.childDbEnv = async () => (globalThis.__ovChatTestChildEnv ? await globalThis.__ovChatTestChildEnv() : {});h.freePort = () => new Promise((r) => { const s = http.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });h.spawnChat = async ({ port, env = {} } = {}) => {const { spawn } = require('child_process');++ // The same database as this process (test/helpers/pg-preload.mjs): the run's schema, or this PGlite over a socket.++ const dbEnv = await h.childDbEnv();const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {cwd: path.join(__dirname, '..'),-- env: { ...process.env, PORT: String(port), ...env },++ env: { ...process.env, ...dbEnv, PORT: String(port), ...env },stdio: ['ignore', 'pipe', 'pipe'],});child.log = '';@@@ -443,7 +446,7 @@ async function boot({ env = {} } = {}) {try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }await new Promise((r) => h.server.close(() => r()));network.close(); liveServer.close(); if (liveServer.closeAllConnections) liveServer.closeAllConnections();-- try { h.db.close(); } catch { /* */ }++ try { await h.db.close(); } catch { /* */ }fs.rmSync(tmp, { recursive: true, force: true });};h.sleep = (ms) => new Promise((r) => setTimeout(r, ms));@@@ -461,6 +464,7 @@ function suite(name) {try { await s.fn(); process.stdout.write(` ✓ ${s.label}\n`); } catch (err) { failed++; process.stdout.write(` ✗ ${s.label}\n${err && err.stack || err}\n`); break; }}try { if (cleanup) await cleanup(); } catch { /* */ }++ try { if (globalThis.__ovChatTestDbClose) await globalThis.__ovChatTestDbClose(); } catch { /* */ }process.stdout.write(`${name}: ${failed ? 'FAILED' : `${steps.length} checks passed`}\n`);process.exit(failed ? 1 : 0);};nnew file mode 100644@@@ -0,0 +1,49 @@++// Loaded with `node --import` into every test process (test/run.js): one migrated database for the process (ADR-035),++// an in-memory PGlite by default or, with OV_TEST_STORE=pg (npm run test:pg), PostgreSQL through PgBouncer with roles++// and a schema of its own (openvibe-sdk/testing createTestDb). server/db/database.js takes it on first use, so test++// files open no database themselves. A Chat the test spawns as its own process (a restart) gets the same database:++// DATABASE_URL/DATABASE_DIRECT_URL of the run's schema, or, on PGlite, this process's instance served over the++// PostgreSQL protocol (openChildDatabase below, with a one-connection pool: PGlite is one session).++import { createRequire } from 'node:module';++import path from 'node:path';++import { fileURLToPath } from 'node:url';++++const require = createRequire(import.meta.url);++const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');++const { createTestDb } = require('openvibe-sdk/testing');++const { createDb } = require('openvibe-sdk/db');++++const migrations = path.join(root, 'migrations');++const quiet = { log() {}, info() {}, warn: (...a) => console.warn(...a), error: (...a) => console.error(...a) };++++if ((process.env.OV_TEST_STORE || 'pglite') === 'pg') {++ const t = await createTestDb({ migrations, store: 'pg', service: 'chat', max: 4, log: quiet });++ globalThis.__ovChatTestDb = t.db;++ globalThis.__ovChatTestChildEnv = async () => ({ DATABASE_URL: t.url, DATABASE_DIRECT_URL: t.directUrl });++ // The owner role on a direct connection, for a test that changes the schema (the runtime role is DML only).++ let owner = null;++ globalThis.__ovChatTestOwnerDb = () => (owner ||= createDb({ url: t.directUrl, service: 'chat-test-owner', max: 1, log: quiet }));++ globalThis.__ovChatTestDbClose = async () => { if (owner) await owner.close().catch(() => {}); await t.close(); };++} else {++ const { PGlite } = require('@electric-sql/pglite');++ const { PARSERS } = require('openvibe-sdk/db');++ const parsers = {};++ for (const [oid, fn] of Object.entries(PARSERS)) parsers[oid] = (v) => fn(v);++ const instance = new PGlite(undefined, { parsers });++ const db = createDb({ pglite: instance, service: 'chat-test', log: quiet });++ await db.migrate({ dir: migrations, log: quiet });++ let server = null;++ globalThis.__ovChatTestDb = db;++ globalThis.__ovChatTestOwnerDb = () => db;++ globalThis.__ovChatTestDbClose = async () => { if (server) await server.stop().catch(() => {}); await db.close().catch(() => {}); };++ globalThis.__ovChatTestChildEnv = async () => {++ if (!server) {++ const { PGLiteSocketServer } = require('@electric-sql/pglite-socket');++ // Several connections may be open at once (the child's migrator closing as its pool opens); the pool has one.++ server = new PGLiteSocketServer({ db: instance, port: 0, host: '127.0.0.1', maxConnections: 4 });++ await server.start();++ }++ const url = `postgres://postgres:[email protected]:${server.server.address().port}/postgres`;++ return { DATABASE_URL: url, DATABASE_DIRECT_URL: url, DATABASE_POOL_MAX: '1' };++ };++}@@@ -8,7 +8,7 @@ const assert = require('assert');const fs = require('fs');const os = require('os');const path = require('path');--const Database = require('better-sqlite3');++const { openSqlite } = require('../scripts/lib/sqlite');const { createDb } = require('openvibe-sdk/db');const { suite } = require('./helpers');const { main } = require('../scripts/import-sqlite-to-pg');@@@ -18,8 +18,8 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));const fixture = path.join(tmp, 'chat.db');const pgliteDir = path.join(tmp, 'pglite');--function buildFixture() {-- const d = new Database(fixture);++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));@@@ -29,18 +29,18 @@ function buildFixture() {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++) msg.run(1000 + i, `user${i}`, `message ${i}`, '2026-09-29 10:00:00');-- d.prepare('INSERT INTO relay_users (platform, username, display_name, message_count) VALUES (?, ?, ?, ?)').run('twitch', 'nightbot', 'Nightbot', 42);-- d.prepare('INSERT INTO rooms (slug, name, owner_id, message_count) VALUES (?, ?, ?, ?)').run('general', 'General', 1001, 5);-- d.prepare('INSERT INTO room_messages (room_id, user_id, message) VALUES (?, ?, ?)').run(1, 1001, 'hi room');-- d.prepare('INSERT INTO chat_meta (key, value) VALUES (?, ?)').run('deploy_head', 'abc123');-- d.prepare('INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (?, ?, ?, ?)').run(1001, 'kek', '/e/kek.png', 1001);++ 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 () => {-- buildFixture();++ 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' });@@@ -11,88 +11,91 @@ const assert = require('assert');const fs = require('fs');const os = require('os');const path = require('path');--const { spawnSync } = require('child_process');--const Database = require('better-sqlite3');++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 chatDb = path.join(tmp, 'chat.db');const SUBJ = 'usr_01J9SUBJECTSUBJECTSUBJECTS';--function importer(args) {-- const r = spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'import-from-live.js'), '--live-db', liveDb, '--chat-db', chatDb, ...args], {-- cwd: path.join(__dirname, '..'), encoding: 'utf8', env: { ...process.env, NODE_ENV: 'test', OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9' },-- });++// 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.status, report, stderr: r.stderr, stdout: r.stdout };++ 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', () => {-- live = new Database(liveDb);++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 (?, ?, '!', ?, ?)");-- u.run(1, 'streamer', 'Streamer', 'streamer'); u.run(2, 'alice', 'Alice', 'user'); u.run(3, 'bob', 'Bob', 'user');-- live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '77', ?)").run(SUBJ);-- live.prepare("INSERT INTO channels (id, user_id, title) VALUES (5, 1, 'TV')").run();-- live.prepare("INSERT INTO managed_streams (id, user_id, channel_id, slug, title, stream_key) VALUES (9, 1, 5, 'main', 'Main', 'k')").run();-- live.prepare("INSERT INTO streams (id, user_id, channel_id, managed_stream_id, title, is_live) VALUES (40, 1, 5, 9, 'Show', 1)").run();++ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');-- msg.run(101, 40, 1, 2, null, 'Alice', 'first', 'chat', null, 0, 0, null, '2026-09-01 10:00:00');-- msg.run(102, 40, 1, 3, null, 'Bob', 'reply', 'chat', null, 0, 0, 101, '2026-09-01 10:00:05');-- msg.run(103, null, null, null, 'anon4', 'anon4', 'global hi', 'chat', null, 1, 0, null, '2026-09-01 10:01:00');-- 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');-- msg.run(105, 40, 1, 2, null, 'Alice', 'deleted one', 'chat', null, 0, 1, null, '2026-09-01 10:03:00');-- live.prepare("INSERT INTO dm_conversations (id, created_by, created_at) VALUES (7, 2, '2026-09-02 00:00:00')").run();-- live.prepare('INSERT INTO dm_participants (conversation_id, user_id) VALUES (7, 2), (7, 3)').run();-- 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();-- live.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id) VALUES (3, 1)').run();-- live.prepare("INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed) VALUES ('user:alice', 'en+f3', 60, 170)").run();-- 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();-- live.prepare("INSERT INTO relay_users (platform, username, display_name, message_count) VALUES ('twitch', 'zed', 'Zed', 3)").run();-- live.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username) VALUES (5, 'twitch', 'spammer')").run();-- 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();-- live.prepare("INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES ('user:2', 1)").run();-- live.prepare("INSERT INTO moderation_actions (scope_type, actor_user_id, target_user_id, action_type, details) VALUES ('channel', 2, 3, 'channel_ban', '{}')").run();-- live.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (5, 2, 1)').run();-- live.prepare('INSERT INTO channel_moderation_settings (channel_id, slow_mode_seconds) VALUES (5, 3)').run();-- live.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (1, 'pog', '/e/pog.png', 1)").run();-- live.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (2, 'og')").run();-- live.prepare("INSERT INTO chat_ai_summaries (scope, subject_id, window, overview) VALUES ('user', 2, '7d', 'nice')").run();-- live.prepare("INSERT INTO chat_timeline_events (scope, ts, label) VALUES ('global', '2026-09-01 10:00:00', 'hype')").run();-- 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();++ 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)', () => {++t('dry run reports and writes nothing (the default; --dry-run says it too)', async () => {for (const args of [[], ['--dry-run']]) {-- const r = importer(args);++ 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 = importer([]);++ 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 = new Database(chatDb, { readonly: true });-- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, 0);-- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM import_hold').get().n, 0);-- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM import_runs').get().n, 0);-- c.close();++ 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', () => {-- const r = importer(['--apply', '--headroom', '1000']);++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 = new Database(r.report.backup, { readonly: true });-- assert.strictEqual(b.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, 0, 'the backup is the database before the run');-- b.close();++ const 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)) {@@@ -102,30 +105,29 @@ t('import copies every table with ids kept, subjects filled, headroom set', () =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 = new Database(chatDb, { readonly: true });-- const m = c.prepare('SELECT * FROM chat_messages WHERE id = 102').get();++ 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(c.prepare('SELECT subject_id FROM chat_messages WHERE id = 101').get().subject_id, SUBJ);-- assert.strictEqual(c.prepare('SELECT is_deleted FROM chat_messages WHERE id = 105').get().is_deleted, 1, 'deleted rows are kept, still deleted');-- assert.strictEqual(c.prepare('SELECT metadata FROM chat_messages WHERE id = 104').get().metadata, '{"kind":"donation","amount":5}');-- assert.strictEqual(c.prepare('SELECT sender_subject_id FROM dm_messages WHERE id = 70').get().sender_subject_id, SUBJ);-- assert.strictEqual(c.prepare('SELECT actor_subject_id FROM moderation_actions').get().actor_subject_id, SUBJ);-- assert.strictEqual(c.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'chat_messages'").get().seq, 1105);-- const hold = c.prepare('SELECT * FROM import_hold').all();++ 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(c.prepare('SELECT username FROM ctx_users WHERE id = 2').get().username, 'alice', 'projections seeded');-- assert.strictEqual(c.prepare('SELECT subject_id FROM ctx_users WHERE id = 2').get().subject_id, SUBJ);-- assert.strictEqual(c.prepare('SELECT slug FROM ctx_managed_streams WHERE id = 9').get().slug, 'main');-- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM live_mirror_outbox').get().n, 0, 'imported rows are never mirrored back to Live');-- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM events_outbox').get().n, 0, 'imported history is not re-announced');-- c.close();++ 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', () => {-- const r = importer(['--apply', '--no-backup', '--headroom', '1000']);++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)) {@@@ -135,39 +137,36 @@ t('re-running is idempotent', () => {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', () => {-- const c = new Database(chatDb);-- c.prepare('UPDATE chat_messages SET is_deleted = 1 WHERE id = 101').run(); // a moderator deleted it in Chat-- c.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (106, 3, 'Bob', 'written in chat')").run();-- c.close();-- live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (106, 2, 'Alice', 'a different 106', '2026-09-03 00:00:00')").run();-- live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (107, 2, 'Alice', 'written in Live after the first pass', '2026-09-03 00:00:01')").run();-- const r = importer(['--apply', '--no-backup', '--headroom', '1000']);++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 = new Database(chatDb, { readonly: true });-- assert.strictEqual(d.prepare('SELECT is_deleted FROM chat_messages WHERE id = 101').get().is_deleted, 1);-- assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 106').get().message, 'written in chat');-- assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 107').get().message, 'written in Live after the first pass');-- const held = d.prepare("SELECT * FROM import_hold WHERE source_table = 'chat_messages'").get();++ 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(d.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'chat_messages'").get().seq, 1107, 'headroom follows Live’s new max, never lowered');-- d.close();++ assert.strictEqual(await lastId('chat_messages'), 1107, 'headroom follows Live’s new max, never lowered');});--t('schema drift is refused before anything is written', () => {++t('schema drift is refused before anything is written', async () => {live.exec('ALTER TABLE chat_messages ADD COLUMN surprise TEXT');-- live.prepare("INSERT INTO chat_messages (id, username, message, surprise) VALUES (108, 'x', 'y', 'z')").run();-- const r = importer([]);++ 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 = new Database(chatDb, { readonly: true });-- assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_messages WHERE id = 108').get().n, 0);-- d.close();++ 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 }); });@@@ -18,8 +18,8 @@ t('boot', async () => {mod = h.addUser('moddy');channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { max_message_length: 60 } });// The settings and moderator rows are Chat's own (C-04 done); seed them here.-- h.setChannelSettings(channelId, { max_message_length: 60 });-- h.db.addChannelModerator(channelId, mod.id, streamer.id);++ await h.setChannelSettings(channelId, { max_message_length: 60 });++ await h.db.addChannelModerator(channelId, mod.id, streamer.id);streamId = h.addStream(streamer.id, channelId);h.live.follows.set(alice.id, [streamer.id]);await h.ctx.sync();@@@ -66,14 +66,14 @@ t('Live down: chat keeps flowing from caches; new tokens fall back to anonymous'});t('invalidation never leaves a gap: a moderator stays a moderator while the policy reloads', async () => {-- h.ctx.invalidateChannel(channelId);-- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);++ await h.ctx.invalidateChannel(channelId);++ assert.strictEqual(await h.ctx.isChannelModerator(mod.id, channelId), true);await h.sleep(100);-- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);++ assert.strictEqual(await h.ctx.isChannelModerator(mod.id, channelId), true);// A moderator removed from Chat's own row loses the power.-- h.db.removeChannelModerator(channelId, mod.id);++ await h.db.removeChannelModerator(channelId, mod.id);await h.sleep(150);-- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), false);++ assert.strictEqual(await h.ctx.isChannelModerator(mod.id, channelId), false);});t('bans: user/stream/site scope, CIDR, and expires_at read as an instant (ISO or SQLite format)', async () => {@@@ -118,7 +118,7 @@ t('a ban written through Live is in Chat’s cache when the effect answers, even});t('followers-only uses the viewer’s warm follow list', async () => {-- h.setChannelSettings(channelId, { followers_only: 1 });++ await h.setChannelSettings(channelId, { followers_only: 1 });await h.sleep(150);const stranger = h.addUser('stranger');await h.ctx.sync();@@@ -45,7 +45,7 @@ t('boot, and some of everything while Live is up', async () => {alice = h.addUser('alice', { subject: ids.newId('user') });bob = h.addUser('bob', { subject: ids.newId('user') });carol = h.addUser('carol', { subject: ids.newId('user') });-- for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }++ for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }[aliceJwt, bobJwt, carolJwt] = [alice, bob, carol].map(jwt);let r = await req('POST', '/api/dm/conversations', { token: aliceJwt, json: { user_ids: [bob.id] } });assert.strictEqual(r.status, 200, r.text);@@@ -25,13 +25,13 @@ const SECRET = 'my number is 555-0199';let h, events, eventsDb, eventsPort, admin, alice, streamer, streamId;/** Outbox chat.message.deleted envelopes after `afterSeq`. */--function deletions(afterSeq = 0) {-- return h.db.all("SELECT seq, event FROM events_outbox WHERE event_type = 'chat.message.deleted' AND seq > ? ORDER BY seq", [afterSeq])++async function deletions(afterSeq = 0) {++ return (await h.db.all("SELECT seq, event FROM events_outbox WHERE event_type = 'chat.message.deleted' AND seq > ? ORDER BY seq", [afterSeq])).map((r) => JSON.parse(r.event));}--const outboxSeq = () => h.db.get('SELECT COALESCE(MAX(seq), 0) AS s FROM events_outbox').s;--function say(message, over = {}) {-- return Number(h.db.saveChatMessage({ stream_id: null, user_id: null, anon_id: 'anon7', username: 'anon7', message, message_type: 'chat', is_global: 1, ...over }).lastInsertRowid);++const outboxSeq = async () => (await h.db.get('SELECT COALESCE(MAX(seq), 0) AS s FROM events_outbox')).s;++async function say(message, over = {}) {++ return Number((await h.db.saveChatMessage({ stream_id: null, user_id: null, anon_id: 'anon7', username: 'anon7', message, message_type: 'chat', is_global: 1, ...over })).lastInsertRowid);}function assertDeletion(env, ids) {const v = validate('events.event-envelope@1', env);@@@ -79,100 +79,100 @@ t('boot (a real OpenVibe.Events on a reserved port when one is checked out next});t('one message deleted (a moderator, or Live’s bridge): one deletion event, ids only', async () => {-- const id = say(SECRET);-- const from = outboxSeq();-- h.db.deleteChatMessage(id, admin.id);-- const [d] = deletions(from);++ const id = await say(SECRET);++ const from = await outboxSeq();++ await h.db.deleteChatMessage(id, admin.id);++ const [d] = await deletions(from);assertDeletion(d, [id]);assert.ok(!JSON.stringify(d).includes('555-0199') && !JSON.stringify(d).includes('anon7'));// Through the bridge, the way Live's /api/mod forwards it.-- const id2 = say('via the bridge');++ const id2 = await say('via the bridge');const r = await h.http('POST', '/internal/live/calls', { token: h.serviceToken(['chat.live_bridge.write']), body: { boot: 'b1', ops: [{ seq: 1, op: 'db', args: ['deleteChatMessage', id2, admin.id] }] } });assert.strictEqual(r.status, 200, r.text);-- assertDeletion(deletions(from).at(-1), [id2]);-- const before = outboxSeq();-- h.db.deleteChatMessage(999999, admin.id);-- assert.strictEqual(outboxSeq(), before, 'nothing announced for a message that does not exist');++ assertDeletion((await deletions(from)).at(-1), [id2]);++ const before = await outboxSeq();++ await h.db.deleteChatMessage(999999, admin.id);++ assert.strictEqual(await outboxSeq(), before, 'nothing announced for a message that does not exist');});t('a user’s, an anon’s and a relay user’s history (self-delete, /api/mod purge)', async () => {-- const mine = [say('a1', { user_id: alice.id, anon_id: null, username: 'alice' }), say('a2', { user_id: alice.id, anon_id: null, username: 'alice' })];-- const anon = [say('n1', { anon_id: 'anon99', username: 'anon99' })];-- const relay = [say('r1', { username: '[Twitch] zed', anon_id: null })];-- let from = outboxSeq();-- assert.deepStrictEqual(h.db.deleteUserChatMessages(alice.id, { deletedBy: alice.id }), mine, 'return value unchanged');-- assertDeletion(deletions(from)[0], mine);-- from = outboxSeq();-- assert.deepStrictEqual(h.db.deleteAnonChatMessages('anon99', { deletedBy: null }), anon);-- assertDeletion(deletions(from)[0], anon);-- from = outboxSeq();-- assert.deepStrictEqual(h.db.deleteRelayUserMessages('[Twitch] zed', { deletedBy: admin.id }), relay);-- assertDeletion(deletions(from)[0], relay);-- from = outboxSeq();-- assert.deepStrictEqual(h.db.deleteUserChatMessages(alice.id, { deletedBy: alice.id }), [], 'nothing left');-- assert.deepStrictEqual(deletions(from), [], 'no empty deletion events');++ const mine = [await say('a1', { user_id: alice.id, anon_id: null, username: 'alice' }), await say('a2', { user_id: alice.id, anon_id: null, username: 'alice' })];++ const anon = [await say('n1', { anon_id: 'anon99', username: 'anon99' })];++ const relay = [await say('r1', { username: '[Twitch] zed', anon_id: null })];++ let from = await outboxSeq();++ assert.deepStrictEqual(await h.db.deleteUserChatMessages(alice.id, { deletedBy: alice.id }), mine, 'return value unchanged');++ assertDeletion((await deletions(from))[0], mine);++ from = await outboxSeq();++ assert.deepStrictEqual(await h.db.deleteAnonChatMessages('anon99', { deletedBy: null }), anon);++ assertDeletion((await deletions(from))[0], anon);++ from = await outboxSeq();++ assert.deepStrictEqual(await h.db.deleteRelayUserMessages('[Twitch] zed', { deletedBy: admin.id }), relay);++ assertDeletion((await deletions(from))[0], relay);++ from = await outboxSeq();++ assert.deepStrictEqual(await h.db.deleteUserChatMessages(alice.id, { deletedBy: alice.id }), [], 'nothing left');++ assert.deepStrictEqual(await deletions(from), [], 'no empty deletion events');});t('self-delete over the socket announces what it deleted', async () => {const ws = await h.ws({ ip: '198.51.100.77', token: alice.token });ws.sendJson({ type: 'join', token: alice.token });await ws.next((m) => m.type === 'auth');-- const id = say('from alice', { user_id: alice.id, anon_id: null, username: 'alice' });-- const from = outboxSeq();++ const id = await say('from alice', { user_id: alice.id, anon_id: null, username: 'alice' });++ const from = await outboxSeq();ws.sendJson({ type: 'self-delete-history' });const res = await ws.next((m) => m.type === 'self-delete-result');assert.strictEqual(res.count, 1);-- assertDeletion(deletions(from)[0], [id]);++ assertDeletion((await deletions(from))[0], [id]);ws.close();});t('a time-range purge (REST) and the auto-delete sweep', async () => {-- const inRoom = [say('s1', { stream_id: streamId, is_global: 0 }), say('s2', { stream_id: streamId, is_global: 0 })];-- const global = say('g1');-- let from = outboxSeq();++ const inRoom = [await say('s1', { stream_id: streamId, is_global: 0 }), await say('s2', { stream_id: streamId, is_global: 0 })];++ const global = await say('g1');++ let from = await outboxSeq();const r = await h.http('DELETE', '/api/chat/admin/purge', { token: admin.token, body: { streamId, from: h.sqliteNow(-60000), to: h.sqliteNow(60000) } });assert.strictEqual(r.status, 200, r.text);assert.strictEqual(r.body.deleted, 2);-- assertDeletion(deletions(from)[0], inRoom);-- assert.ok(!h.db.getChatMessageById(global).is_deleted, 'the other room is untouched');++ assertDeletion((await deletions(from))[0], inRoom);++ assert.ok(!(await h.db.getChatMessageById(global)).is_deleted, 'the other room is untouched');// The dashboard sends ISO instants ('…T…Z'); rows keep 'YYYY-MM-DD HH:MM:SS'. Read as TEXT,// a same-day range matched nothing. Preview, purge and the log filter read both forms.-- const older = say('s-older', { stream_id: streamId, is_global: 0 });-- h.db.run('UPDATE chat_messages SET timestamp = ? WHERE id = ?', [h.sqliteNow(-10 * 60e3), older]);-- const recent = [say('s3', { stream_id: streamId, is_global: 0 }), say('s4', { stream_id: streamId, is_global: 0 })];++ const older = await say('s-older', { stream_id: streamId, is_global: 0 });++ await h.db.run('UPDATE chat_messages SET timestamp = ? WHERE id = ?', [h.sqliteNow(-10 * 60e3), older]);++ const recent = [await say('s3', { stream_id: streamId, is_global: 0 }), await say('s4', { stream_id: streamId, is_global: 0 })];const range = { streamId, from: new Date(Date.now() - 60e3).toISOString(), to: new Date(Date.now() + 60e3).toISOString() };assert.strictEqual((await h.http('POST', '/api/chat/admin/purge/preview', { token: admin.token, body: range })).body.count, 2);const logs = await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}&from=${encodeURIComponent(range.from)}&to=${encodeURIComponent(range.to)}`, { token: admin.token });assert.deepStrictEqual(logs.body.rows.map((m) => m.id).sort(), [...recent].sort(), 'the log filter reads ISO too');-- from = outboxSeq();++ from = await outboxSeq();const iso = await h.http('DELETE', '/api/chat/admin/purge', { token: admin.token, body: range });assert.strictEqual(iso.body.deleted, 2, iso.text);-- assertDeletion(deletions(from)[0], recent);-- assert.ok(!h.db.getChatMessageById(older).is_deleted, 'ten minutes ago is outside the range');-- const expiring = say('ephemeral', { auto_delete_at: h.sqliteNow(-1000) });-- from = outboxSeq();-- const swept = h.db.deleteExpiredChatMessages(500);++ assertDeletion((await deletions(from))[0], recent);++ assert.ok(!(await h.db.getChatMessageById(older)).is_deleted, 'ten minutes ago is outside the range');++ const expiring = await say('ephemeral', { auto_delete_at: h.sqliteNow(-1000) });++ from = await outboxSeq();++ const swept = await h.db.deleteExpiredChatMessages(500);assert.deepStrictEqual(swept.map((x) => x.id), [expiring]);-- assertDeletion(deletions(from)[0], [expiring]);++ assertDeletion((await deletions(from))[0], [expiring]);});t('more than 500 ids: one event per 500, in order', async () => {const ids = [];-- h.db.transaction(() => { for (let i = 0; i < 1203; i++) ids.push(say(`bulk ${i}`, { anon_id: 'anon500', username: 'anon500' })); });-- const from = outboxSeq();-- h.db.deleteAnonChatMessages('anon500', { deletedBy: admin.id });-- const d = deletions(from);++ await h.db.tx(async () => { for (let i = 0; i < 1203; i++) ids.push(await say(`bulk ${i}`, { anon_id: 'anon500', username: 'anon500' })); });++ const from = await outboxSeq();++ await h.db.deleteAnonChatMessages('anon500', { deletedBy: admin.id });++ const d = await deletions(from);assert.deepStrictEqual(d.map((e) => e.payload.message_ids.length), [500, 500, 203]);assert.deepStrictEqual(d.flatMap((e) => e.payload.message_ids), ids);for (const e of d) assert.ok(Buffer.byteLength(JSON.stringify(e.payload)) < 64 * 1024, 'fits Events’ payload limit');});t('a failed delete leaves no event (same transaction)', async () => {-- const id = say('stays');-- const from = outboxSeq();-- assert.throws(() => h.db.transaction(() => { h.db.deleteChatMessage(id, admin.id); throw new Error('boom'); }), /boom/);-- assert.deepStrictEqual(deletions(from), []);-- assert.ok(!h.db.getChatMessageById(id).is_deleted);++ const id = await say('stays');++ const from = await outboxSeq();++ await assert.rejects(async () => await h.db.tx(async () => { await h.db.deleteChatMessage(id, admin.id); throw new Error('boom'); }), /boom/);++ assert.deepStrictEqual(await deletions(from), []);++ assert.ok(!(await h.db.getChatMessageById(id)).is_deleted);});// ── End to end with OpenVibe.Events ──────────────────────────────@@@ -208,8 +208,8 @@ const userJwt = (subjectId) => serviceAuth.signServiceToken({let firstSeq, msgId;t('e2e: the relay publishes the message, then its deletion; replay serves only the tombstone', async () => {if (!haveEvents) return;-- h.db.run('UPDATE events_outbox SET sent_at = ? WHERE sent_at IS NULL', [new Date().toISOString()]); // earlier tests' rows-- msgId = say(SECRET, { user_id: alice.id, anon_id: null, username: 'alice' });++ await h.db.run('UPDATE events_outbox SET sent_at = ? WHERE sent_at IS NULL', [new Date().toISOString()]); // earlier tests' rows++ msgId = await say(SECRET, { user_id: alice.id, anon_id: null, username: 'alice' });assert.strictEqual((await h.eventsRelay.flush()).sent, 1);firstSeq = await events.store.lastSeq();const before = await sse(`/realtime/stream?topics=chat.message.*&last_event_id=${firstSeq - 1}`);@@@ -217,7 +217,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only tassert.ok(before.body().includes('555-0199'), 'before the deletion the text is there (the leak)');before.close();-- h.db.deleteChatMessage(msgId, admin.id);++ await h.db.deleteChatMessage(msgId, admin.id);assert.strictEqual((await h.eventsRelay.flush()).sent, 1);const views = [['anonymous', {}],@@@ -246,10 +246,10 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only tt('e2e: only svc:chat can redact Chat’s events', async () => {if (!haveEvents) return;-- const id = say('not yours to delete');++ const id = await say('not yours to delete');await h.eventsRelay.flush();const created = (await events.store.scan(0, { patterns: ['chat.message.created'], limit: 1000 })).rows.find((r) => r.subject_id === String(id));-- const env = deletions(0).at(-1); // a well-formed deletion, re-sourced by another service++ const env = (await deletions(0)).at(-1); // a well-formed deletion, re-sourced by another serviceconst post = (sub, body) => fetch(`http://127.0.0.1:${eventsPort}/api/v1/events`, {method: 'POST', body: JSON.stringify(body),headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${h.serviceToken(['events.event.publish'], { aud: 'openvibe.events', sub })}` },@@@ -260,7 +260,7 @@ t('e2e: only svc:chat can redact Chat’s events', async () => {r = await post('svc:live', { ...env, event_id: 'evt_01J9DDDDDDDDDDDDDDDDDDDDDD', payload: { redacts: { subject_type: 'chat_message', subject_ids: [String(id)] } } });assert.strictEqual(r.status, 403, 'svc:live cannot publish as chat');assert.ok((await events.store.getEvent(created.id)).payload.includes('not yours to delete'), 'still intact');-- h.db.deleteChatMessage(id, admin.id);++ await h.db.deleteChatMessage(id, admin.id);await h.eventsRelay.flush();assert.ok((await events.store.getEvent(created.id)).redacted_at, 'Chat itself can');});@@@ -15,7 +15,7 @@ const assert = require('assert');const fs = require('fs');const path = require('path');const { spawn } = require('child_process');--const Database = require('better-sqlite3');++const { openSqlite } = require('../scripts/lib/sqlite');const { boot, suite } = require('./helpers');const t = suite('migrate-preferences');@@@ -42,22 +42,22 @@ 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 = new Database(livePath);++ 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 = (id, name, settings, links) => {-- live.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, name);-- for (const [service, subject] of links) live.prepare('INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, ?, ?, ?)').run(id, service, `x${id}`, subject);-- live.prepare('INSERT INTO user_preferences (user_id, chat_settings) VALUES (?, ?)').run(id, typeof settings === 'string' ? settings : JSON.stringify(settings));++ 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));};-- add(1, 'ann', { ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false }, [['network', S.ann]]);-- add(2, 'bob', liveDefaults, [['network', S.bob]]); // defaults only-- add(3, 'cat', { ...liveDefaults, compactMode: true }, []); // no subject-- add(4, 'dan', '{not json', [['network', 'usr_01J9DAN0000000000000000AAA']]); // unreadable-- add(5, 'eve', { ...liveDefaults, compactMode: true, fontSize: 'small' }, [['network', S.eve]]); // has a record already-- add(6, 'fay', { compactMode: true }, [['network', S.fay]]); // a partial old blob-- add(7, 'gus', { ...liveDefaults, fontSize: 'small' }, [['hobostreamer', S.gusOld], ['network', S.gus]]); // the network link wins++ 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 winslive.close();for (const s of Object.values(S)) h.netModules.subjects.add(s);h.netModules.set(NS, S.eve, { hide_emotes: true });@@@ -72,7 +72,7 @@ t('the mapping keeps choices only', () => {});t('plan: counts and targets from Live, read-only', () => {-- const live = new Database(livePath, { readonly: true });++ 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 } });@@@ -107,7 +107,7 @@ t('dry run through the real script: a report, reads only', async () => {t('--apply --backup: backup first (0600), create-only writes, revisions recorded', async () => {const backup = path.join(h.tmp, 'prefs-backup-1.json');-- const live = new Database(livePath, { readonly: true });++ const live = openSqlite(livePath);const planned = mig.plan(live);live.close();await assert.rejects(mig.migrate(planned, { apply: true }), /--backup/);@@@ -26,7 +26,6 @@ const assert = require('assert');const fs = require('fs');const os = require('os');const path = require('path');--const Database = require('better-sqlite3');const compat = require('openvibe-shared/release-compat');const h = require('./n-1/harness');const svc = require('./n-1/service');@@@ -34,21 +33,21 @@ const svc = require('./n-1/service');const ROOT = path.join(__dirname, '..');const FIX = path.join(__dirname, 'fixtures', 'n-1');--// ── Skipped for this release only: the SQLite→PostgreSQL engine switch (plan T3) ────────────────--// This release moves Chat's storage to PostgreSQL (/ready says pglite or postgresql, never a SQLite--// file), so the N-1 fixture — the previous release's statements, collected from its *SQLite* schema and--// dialect — can no longer be prepared or replayed here. The harness and the fixtures stay; after this--// release deploys, `npm run n-1:record` records the previous release's statements from its PostgreSQL--// schema and the replay resumes. Until then the run reports itself skipped (the ○ convention: it is--// neither green nor a failure). Do not delete the harness.--const ENGINE_SWITCH_TO_POSTGRES = true;--if (ENGINE_SWITCH_TO_POSTGRES) {++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);}----const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));--const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));++const { createDb } = require('openvibe-sdk/db');++const quiet = { log() {}, info() {}, warn() {}, error: (...a) => console.error(...a) };let failures = 0;async function check(name, fn) {@@@ -77,36 +76,38 @@ async function check(name, fn) {assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: '2' }] }), ['streams[].viewer_count was number, now string', 'total is gone']);assert.ok(h.statusCompatible(200, 201) && h.statusCompatible(502, 503) && !h.statusCompatible(401, 403) && !h.statusCompatible(502, 404) && !h.statusCompatible(200, 404));});-- await check('SQL: a dropped column fails the prepare; a new NOT NULL column without a default fails an old INSERT', () => {-- const d = new Database(':memory:');-- d.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT NOT NULL, c TEXT NOT NULL DEFAULT \'x\')');-- assert.deepStrictEqual(h.prepareProblems(d, ['SELECT a, gone FROM t']).map((p) => p.error), ['no such column: gone']);-- assert.deepStrictEqual(h.insertProblems(d, ['INSERT INTO t (a, b) VALUES (?, ?)', 'INSERT INTO t (a) VALUES (?)']).map((p) => p.error), ['t.b is NOT NULL without a default, and N-1 does not set it']);-- d.close();++ await check('SQL: a dropped column fails the prepare; a new NOT NULL column without a default fails an old INSERT', async () => {++ const d = createDb({ pglite: true, log: quiet });++ await d.query('CREATE TABLE t (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, a text, b text NOT NULL, c text NOT NULL DEFAULT \'x\')');++ assert.deepStrictEqual((await h.pgPrepareProblems(d, ['SELECT a, gone FROM t'])).map((p) => p.error), ['column "gone" does not exist']);++ assert.deepStrictEqual((await h.pgInsertProblems(d, ['INSERT INTO t (a, b) VALUES ($1, $2)', 'INSERT INTO t (a) VALUES ($1)'])).map((p) => p.error), ['t.b is NOT NULL without a default, and N-1 does not set it']);++ await d.close();});const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-test-'));-- const dbPath = path.join(tmp, 'db', `${svc.service}.db`);++ const dbPath = path.join(tmp, 'db', 'pglite');const dataDir = path.join(tmp, 'data');fs.mkdirSync(path.dirname(dbPath), { recursive: true });fs.mkdirSync(dataDir, { recursive: true });let server = null;try {console.log(`n-1: this release on a database N-1 created`);-- await check('N-1\'s schema, ledger and first-use tables load, and this release migrates and seeds it', () => {-- const d = new Database(dbPath);-- d.transaction(() => {-- for (const ddl of worker.schema) d.exec(ddl);-- for (const [table, rows] of Object.entries(worker.ledger || {})) {-- for (const row of rows) {-- const cols = Object.keys(row);-- d.prepare(`INSERT INTO "${table}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...cols.map((c) => row[c]));++ await check('N-1\'s schema (the migrations it applied) and ledger load, and this release migrates and seeds it', async () => {++ const dir = path.join(tmp, 'n-1-migrations');++ fs.mkdirSync(dir, { recursive: true });++ for (const m of worker.migrations) fs.writeFileSync(path.join(dir, m.file), m.sql);++ const d = createDb({ pglite: dbPath, log: quiet });++ try {++ await d.migrate({ dir, log: quiet });++ await d.tx(async (t) => {++ for (const [table, rows] of Object.entries(worker.ledger || {})) {++ for (const row of rows) {++ const cols = Object.keys(row);++ await t.query(`INSERT INTO "${table}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(', ')})`, cols.map((c) => row[c]));++ }}-- }-- for (const ddl of worker.lazy || []) d.exec(ddl);-- })();-- d.pragma(`user_version = ${Number(worker.user_version) || 0}`);-- d.close();++ });++ } finally { await d.close(); }svc.seed({ dir: ROOT, dbPath, dataDir });});server = await svc.boot({ dir: ROOT, dbPath, dataDir });@@@ -139,10 +140,10 @@ async function check(name, fn) {server = null;console.log('n-1: N-1\'s SQL on the schema this release migrated');-- await check(`every statement N-1 runs still prepares (${worker.statements.length}), and none of its INSERTs misses a new required column`, () => {-- const d = new Database(dbPath, { readonly: true });-- const problems = [...h.prepareProblems(d, worker.statements), ...h.insertProblems(d, worker.statements)];-- d.close();++ await check(`every statement N-1 runs still prepares (${worker.statements.length}), and none of its INSERTs misses a new required column`, async () => {++ const d = createDb({ pglite: dbPath, log: quiet });++ let problems;++ try { problems = [...await h.pgPrepareProblems(d, worker.statements), ...await h.pgInsertProblems(d, worker.statements)]; } finally { await d.close(); }assert.deepStrictEqual(problems.map((p) => `${p.error}: ${p.sql.slice(0, 160)}`), [], 'N-1 SQL that breaks on this schema (expand first, contract a release later: ADR-028)');});} finally {@@@ -1,7 +1,8 @@'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, seeds it,++ * 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,* and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it (Chat's own shutdown).*/const path = require('path');@@@ -11,27 +12,27 @@ 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 } });++ const h = await helpers.boot({ env: { CHAT_DB_PATH: process.env.N1_DB, 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');const channel = h.addChannel(star.id);const stream = h.addStream(star.id, channel, { title: 'N-1 live stream' });const db = require(path.join(dir, 'server', 'db', 'database'));-- db.saveChatMessage({ stream_id: stream, channel_user_id: star.id, user_id: fan.id, username: 'n1fan', message: 'hello from N-1' });-- db.saveChatMessage({ user_id: fan.id, username: 'n1fan', message: 'global hello from N-1', is_global: 1 });++ await db.saveChatMessage({ stream_id: stream, channel_user_id: star.id, user_id: fan.id, username: 'n1fan', message: 'hello from N-1' });++ await db.saveChatMessage({ user_id: fan.id, username: 'n1fan', message: 'global hello from N-1', is_global: 1 });let room = null;try {const rooms = require(path.join(dir, 'server', 'rooms', 'rooms'));-- room = rooms.create(star, { name: 'N-1 room', slug: 'n1-room' });-- rooms.post(rooms.bySlug('n1-room'), star, 'first post from N-1');++ room = await rooms.create(star, { name: 'N-1 room', slug: 'n1-room' });++ await rooms.post(await rooms.bySlug('n1-room'), star, 'first post from N-1');} catch (err) { process.stderr.write(`[n-1] room seed: ${err.message}\n`); }let conversation = null;try {const dm = require(path.join(dir, 'server', 'chat', 'dm'));-- const c = dm.getOrCreateDirect(star.id, fan.id);++ const c = await dm.getOrCreateDirect(star.id, fan.id);conversation = c && (c.id || c.conversation_id || c);-- dm.sendMessage(conversation, fan.id, 'a direct hello from N-1');++ await dm.sendMessage(conversation, fan.id, 'a direct hello from N-1');} catch (err) { process.stderr.write(`[n-1] dm seed: ${err.message}\n`); }process.stdout.write(`${JSON.stringify({ n1: { url: h.base, token: star.token, ids: { star: star.id, fan: fan.id, channel, stream, room: room && room.slug, conversation } } })}\n`);})().catch((err) => { process.stderr.write(`${err.stack || err.message}\n`); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,12 @@++'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 };@@@ -812,6 +812,55 @@ function insertProblems(db, statements) {return problems;}++// ── PostgreSQL (a release on openvibe-sdk/db, plan T3) ─────────++++/**++ * The migrations a release applied (ov_migrations), each with its file's text from `dir`: its schema, rebuilt++ * exactly by applying them again (migrations are append-only and checksummed). → [{ file, sql }]++ */++async function pgMigrations(db, dir) {++ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^\d{4,}_.+\.sql$/.test(f)) : [];++ const out = [];++ for (const r of await db.many('SELECT id FROM ov_migrations ORDER BY id')) {++ const file = files.find((f) => Number(f.split('_')[0]) === Number(r.id));++ if (file) out.push({ file, sql: fs.readFileSync(path.join(dir, file), 'utf8') });++ }++ return out;++}++++/** PREPAREs every statement on db (a missing table or column, or a type that no longer fits, fails). → [{ sql, error }] */++async function pgPrepareProblems(db, statements) {++ const problems = [];++ for (const sql of statements) {++ try {++ await db.tx(async (t) => { await t.query(`PREPARE n1_check AS ${sql}`); await t.query('DEALLOCATE n1_check'); });++ } catch (e) { problems.push({ sql, error: e.message }); }++ }++ return problems;++}++++/** insertProblems on PostgreSQL: a column now NOT NULL without a default (nor identity, nor generated) that an N-1 INSERT does not name. */++async function pgInsertProblems(db, statements) {++ const problems = [];++ const cols = new Map();++ const required = async (table) => {++ if (!cols.has(table)) {++ cols.set(table, (await db.many(`SELECT column_name FROM information_schema.columns++ WHERE table_schema = current_schema() AND table_name = $1 AND is_nullable = 'NO' AND column_default IS NULL++ AND is_identity = 'NO' AND is_generated = 'NEVER'`, [table])).map((r) => r.column_name));++ }++ return cols.get(table);++ };++ for (const sql of statements) {++ const m = /^\s*INSERT\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 = (await 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;++}++// ── Git ──────────────────────────────────────────────────────/** A detached worktree of `ref` in a temp directory sharing root's node_modules → { dir, sha, remove() }. */@@@ -852,4 +901,5 @@ module.exports = {wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,worktree, gitFiles, summarize,++ pgMigrations, pgPrepareProblems, pgInsertProblems,};@@@ -2,14 +2,30 @@/*** 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 (CHAT_DB_PATH, read when the statement runs) is written there as JSON-- * when it exits.++ * 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).*/const fs = require('fs');const path = require('path');const out = process.env.N1_SQL_OUT;--if (out) {++const onPostgres = out && require('./engine').onPostgres(process.cwd());++if (out && onPostgres) {++ const sdkDb = require(require.resolve('openvibe-sdk/db', { paths: [process.cwd()] }));++ const seen = new Set();++ const createDb = sdkDb.createDb;++ sdkDb.createDb = function (o) {++ const d = createDb(o);++ const query = d.query;++ d.query = (q, values) => {++ try { seen.add(typeof q === 'string' ? q : q.compile().text); } catch { /* */ }++ return query(q, values);++ };++ 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) => {@@@ -40,22 +40,22 @@ t('boot', async () => {t('the projection keeps the newest revision per pair', async () => {const nb = require('../server/chat/network-blocks');const e1 = event(bob.subject_id, alice.subject_id, true, 1);-- assert.strictEqual(h.eventsConsumer.apply(e1).outcome, 'blocked');-- assert.strictEqual(h.eventsConsumer.apply(e1).duplicate, true, 'a redelivery is a no-op');-- assert.ok(nb.hasBlocked(bob.subject_id, alice.subject_id));-- assert.ok(!nb.hasBlocked(alice.subject_id, bob.subject_id), 'one direction');-- assert.ok(nb.eitherBlockedUsers(alice.id, bob.id) && nb.eitherBlockedUsers(bob.id, alice.id));++ assert.strictEqual((await h.eventsConsumer.apply(e1)).outcome, 'blocked');++ assert.strictEqual((await h.eventsConsumer.apply(e1)).duplicate, true, 'a redelivery is a no-op');++ assert.ok(await nb.hasBlocked(bob.subject_id, alice.subject_id));++ assert.ok(!await nb.hasBlocked(alice.subject_id, bob.subject_id), 'one direction');++ assert.ok(await nb.eitherBlockedUsers(alice.id, bob.id) && await nb.eitherBlockedUsers(bob.id, alice.id));// Out of order: revision 3 (unblock) then the late revision 2 (block) changes nothing.-- assert.strictEqual(h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, false, 3)).outcome, 'unblocked');-- assert.strictEqual(h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 2)).outcome, 'unchanged', 'an older revision never wins');-- assert.ok(!nb.hasBlocked(dave.subject_id, carol.subject_id));++ assert.strictEqual((await h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, false, 3))).outcome, 'unblocked');++ assert.strictEqual((await h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 2))).outcome, 'unchanged', 'an older revision never wins');++ assert.ok(!await nb.hasBlocked(dave.subject_id, carol.subject_id));// Only Network's, only valid payloads.-- assert.strictEqual(h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 9, { source: 'live' })).outcome, 'ignored:source');++ assert.strictEqual((await h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 9, { source: 'live' }))).outcome, 'ignored:source');const bad = event(dave.subject_id, carol.subject_id, true, 9); bad.payload.blocked = 'carol';-- assert.strictEqual(h.eventsConsumer.apply(bad).outcome, 'ignored:payload');++ assert.strictEqual((await h.eventsConsumer.apply(bad)).outcome, 'ignored:payload');const zero = event(dave.subject_id, carol.subject_id, true, 0);-- assert.strictEqual(h.eventsConsumer.apply(zero).outcome, 'ignored:payload', 'revision starts at 1');-- assert.ok(!nb.hasBlocked(dave.subject_id, carol.subject_id));++ assert.strictEqual((await h.eventsConsumer.apply(zero)).outcome, 'ignored:payload', 'revision starts at 1');++ assert.ok(!await nb.hasBlocked(dave.subject_id, carol.subject_id));});t('DMs: bob blocked alice on the network — refused both ways, same shapes as dm_blocks', async () => {@@@ -86,7 +86,7 @@ t('DMs: bob blocked alice on the network — refused both ways, same shapes as d});t('an unblock lifts it', async () => {-- assert.strictEqual(h.eventsConsumer.apply(event(bob.subject_id, alice.subject_id, false, 2)).outcome, 'unblocked');++ assert.strictEqual((await h.eventsConsumer.apply(event(bob.subject_id, alice.subject_id, false, 2))).outcome, 'unblocked');let r = await h.http('POST', `/api/dm/conversations/${convAB}/messages`, { token: alice.token, body: { message: 'hi again' } });assert.strictEqual(r.status, 200, r.text);r = await h.http('POST', `/api/dm/conversations/${group}/participants`, { token: carol.token, body: { user_id: alice.id } });@@@ -26,7 +26,7 @@ t('boot', async () => {alice = h.addUser('alice', { subject: ids.newId('user') });carol = h.addUser('carol', { subject: ids.newId('user'), role: 'global_mod' });modSubject = ids.newId('user');-- for (const u of [alice, carol]) h.ctx.upsertUser(h.live.users.get(u.id));++ for (const u of [alice, carol]) await h.ctx.upsertUser(h.live.users.get(u.id));});t('a Network token for a projected subject resolves here, without Live', async () => {nnew file mode 100644@@@ -0,0 +1,46 @@++'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.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { suite } = require('./helpers');++++const t = suite('no-sqlite');++const ROOT = path.join(__dirname, '..');++++function files(dir) {++ const out = [];++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const p = path.join(dir, e.name);++ if (e.isDirectory()) out.push(...files(p));++ else if (/\.(js|mjs|cjs)$/.test(e.name)) out.push(p);++ }++ return out;++}++++t('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('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('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.run();@@@ -45,8 +45,8 @@ t('a REST fallback message, a DM and a moderation action each leave one envelopeassert.deepStrictEqual(sent.body, { ok: true });const conv = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });await h.http('POST', `/api/dm/conversations/${conv.body.conversation.id}/messages`, { token: alice.token, body: { message: 'dm text' } });-- h.db.logModerationAction({ scope_type: 'site', actor_user_id: alice.id, target_user_id: bob.id, action_type: 'site_timeout', details: { duration: 60 } });-- const rows = h.db.all('SELECT event_type, event, sent_at FROM events_outbox ORDER BY seq');++ await h.db.logModerationAction({ scope_type: 'site', actor_user_id: alice.id, target_user_id: bob.id, action_type: 'site_timeout', details: { duration: 60 } });++ const rows = await h.db.all('SELECT event_type, event, sent_at FROM events_outbox ORDER BY seq');assert.deepStrictEqual(rows.map((r) => r.event_type), ['chat.message.created', 'chat.dm.created', 'chat.moderation.action']);for (const r of rows) {const env = JSON.parse(r.event);@@@ -70,21 +70,21 @@ t('a REST fallback message, a DM and a moderation action each leave one envelopet('Events down: rows wait with the error; back up: published once, marked sent', async () => {eventsMode = 'down';await h.eventsRelay.flush();-- const waiting = h.db.all('SELECT attempts, last_error, sent_at FROM events_outbox');++ const waiting = await h.db.all('SELECT attempts, last_error, sent_at FROM events_outbox');assert.ok(waiting.every((r) => r.attempts === 1 && /503/.test(r.last_error) && r.sent_at === null));eventsMode = 'ok';const r = await h.eventsRelay.flush();assert.strictEqual(r.sent, 3);assert.strictEqual(received.length, 3);-- assert.ok(h.db.all('SELECT sent_at FROM events_outbox').every((x) => x.sent_at));++ assert.ok((await h.db.all('SELECT sent_at FROM events_outbox')).every((x) => x.sent_at));assert.strictEqual((await h.eventsRelay.flush()).sent, 0, 'nothing twice');assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.events' && x.scope === 'events.event.publish'));});t('mirror: every change to Chat’s tables reaches Live, newest state per row', async () => {-- const id = Number(h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'to be deleted', message_type: 'chat' }).lastInsertRowid);-- h.db.deleteChatMessage(id, streamer.id);-- h.db.recordFirstChat(`user:${bob.id}`, streamer.id);++ const id = Number((await h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'to be deleted', message_type: 'chat' })).lastInsertRowid);++ await h.db.deleteChatMessage(id, streamer.id);++ await h.db.recordFirstChat(`user:${bob.id}`, streamer.id);await h.mirrorRelay.flush();const byTable = (tbl) => h.live.mirror.filter((c) => c.table === tbl);const msgChanges = byTable('chat_messages').filter((c) => c.row && c.row.id === id);@@@ -95,10 +95,10 @@ t('mirror: every change to Chat’s tables reaches Live, newest state per row',assert.ok(byTable('dm_messages').length === 1 && byTable('dm_participants').length === 2 && byTable('dm_conversations').length >= 1);assert.ok(byTable('moderation_actions').length === 1);assert.deepStrictEqual(byTable('stream_first_chats')[0].row.chatter_key, `user:${bob.id}`);-- assert.strictEqual(h.mirrorRelay.pending(), 0);++ assert.strictEqual(await h.mirrorRelay.pending(), 0);// A delete travels as a delete.-- const dmId = h.db.get('SELECT id FROM dm_messages').id;-- const conv = h.db.get('SELECT conversation_id FROM dm_messages').conversation_id;++ const dmId = (await h.db.get('SELECT id FROM dm_messages')).id;++ const conv = (await h.db.get('SELECT conversation_id FROM dm_messages')).conversation_id;assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${conv}/messages/${dmId}`, { token: alice.token })).status, 200);await h.mirrorRelay.flush();assert.deepStrictEqual(h.live.mirror.at(-1), { table: 'dm_messages', op: 'delete', pk: { id: dmId } });@@@ -106,7 +106,7 @@ t('mirror: every change to Chat’s tables reaches Live, newest state per row',t('mirror: Live refusing (not yet CHAT_AUTHORITY=chat) keeps the queue; accepted later', async () => {h.live.mirrorStatus = 409;-- h.db.recordRelayUser('twitch', 'Zed');++ await h.db.recordRelayUser('twitch', 'Zed');const r = await h.mirrorRelay.flush();assert.ok(r.pending >= 1 && /409/.test(r.error));h.live.mirrorStatus = 200;@@@ -120,17 +120,18 @@ t('scripts/mirror-flush.js drains the queue without the service (rollback)', asyconst { spawn } = require('child_process');const path = require('path');h.live.mirrorStatus = 409;-- h.db.recordRelayUser('twitch', 'Late');++ await h.db.recordRelayUser('twitch', 'Late');await h.mirrorRelay.flush();-- assert.ok(h.mirrorRelay.pending() >= 1);++ assert.ok(await h.mirrorRelay.pending() >= 1);h.live.mirrorStatus = 200;++ const env = { ...process.env, ...(await h.childDbEnv()) };const r = await new Promise((resolve) => {-- const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'mirror-flush.js')], { env: { ...process.env } });++ const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'mirror-flush.js')], { env });let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { out += d; }); c.on('close', (code) => resolve({ code, out }));});assert.strictEqual(r.code, 0, r.out);assert.strictEqual(JSON.parse(r.out.slice(r.out.indexOf('{'))).pending, 0);-- assert.strictEqual(h.mirrorRelay.pending(), 0);++ assert.strictEqual(await h.mirrorRelay.pending(), 0);assert.strictEqual(h.live.mirror.at(-1).row.username, 'late');});@@@ -48,9 +48,9 @@ const driver = {connect: ({ role, stream }) => h.ws({ ip: IPS[role], stream }),http: (method, path, { token, body } = {}) => h.http(method, path, { token, body }),// A network block, as the Events consumer applies network.block.changed.-- block: (blocker, blocked, active) => {++ block: async (blocker, blocked, active) => {const subject = { a: alice, b: bob, mod, streamer }[blocker].subject_id, target = { a: alice, b: bob, mod, streamer }[blocked].subject_id;-- require('../server/chat/network-blocks').apply({ blocker: subject, blocked: target, active, revision: ++blockRev });++ await require('../server/chat/network-blocks').apply({ blocker: subject, blocked: target, active, revision: ++blockRev });},};@@@ -62,7 +62,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sybob = h.addUser('bob', { subject: 'usr_01J9PAR1TY00000000000000B1' });dave = h.addUser('dave');channelId = h.addChannel(streamer.id, { moderators: [mod.id] });-- h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)++ await h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)streamId = h.addStream(streamer.id, channelId);h.live.subscribers.add(`${alice.id}|${streamer.id}`); // A subscribes to the channel; B does notawait h.ctx.sync();@@@ -104,8 +104,8 @@ t('dm: delivery keeps the participant rule (chatServer.sendDm checks dm.isParticconst convId = conv.body.conversation.id;const outsider = await p.join('mod', { stream: false });const bobWs = await p.join('b', { stream: false });-- h.chatServer.sendDm(mod.id, { type: 'dm', conversation_id: convId, message: { message: 'not for you' } });-- h.chatServer.sendDm(bob.id, { type: 'dm', conversation_id: convId, message: { message: 'for bob' } });++ await h.chatServer.sendDm(mod.id, { type: 'dm', conversation_id: convId, message: { message: 'not for you' } });++ await h.chatServer.sendDm(bob.id, { type: 'dm', conversation_id: convId, message: { message: 'for bob' } });await bobWs.next((m) => m.type === 'dm' && m.message.message === 'for bob');assert.ok(await outsider.none((m) => m.type === 'dm'), 'a non-participant never receives it');await p.closeAll();@@@ -197,7 +197,7 @@ t('sub-only fails closed: while Live cannot say whether someone subscribes theyt('slow mode is the channel’s saved setting: the dashboard’s value is enforced and announced, and a restart keeps it', async () => {const a = await p.join('a'); const b = await p.join('b');// The dashboard saves 3 s in Chat's own settings row, which announces the change at once.-- h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 3 });++ await h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 3 });assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, 3, 'the room is told');await p.system(b, 'Slow mode enabled: 3s between messages');const slowed = async (ws, label) => {@@@ -216,7 +216,7 @@ t('slow mode is the channel’s saved setting: the dashboard’s value is enforcassert.strictEqual(again.auth.slowmode_seconds, 3, 'read back after a restart');await slowed(again, 'after a restart');// The dashboard turns it off.-- h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 0 });++ await h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 0 });assert.strictEqual((await b.next((m) => m.type === 'slowmode' && m.seconds === 0)).seconds, 0);await p.system(b, 'Slow mode disabled.');await p.closeAll();@@@ -273,11 +273,11 @@ t('the CLI: a dry run lists every scenario and opens nothing; --apply refuses acassert.strictEqual(h.chatServer.clients.size, sockets, 'no connection');assert.strictEqual(h.live.requests.length, requests, 'nothing reached Chat');// --apply: moddy and the channel owner are not named → nothing runs.-- const before = h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n;++ const before = (await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n;out.length = 0;assert.strictEqual(await parity.main(['--apply'], env, (l) => out.push(l)), 2);assert.match(out.join('\n'), /refused: moddy, streamer are not named in OV_PARITY_TEST_ACCOUNTS; nothing was run/);-- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n, before, 'no message was written');++ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n, before, 'no message was written');// Named: the CLI's own network client runs a scenario end to end.out.length = 0;assert.strictEqual(await parity.main(['--apply', '--only', 'join,send'], { ...env, OV_PARITY_TEST_ACCOUNTS: 'Alice, bob, moddy, streamer' }, (l) => out.push(l)), 0, out.join('\n'));@@@ -82,7 +82,8 @@ t('a step that misses its own schedule is late on its own (not masked by the fas});t('database failing: 503 not_ready naming the db check', async () => {-- h.db.run('ALTER TABLE chat_messages RENAME TO chat_messages_away');++ const owner = globalThis.__ovChatTestOwnerDb(); // the runtime role cannot change the schema++ await owner.query('ALTER TABLE chat_messages RENAME TO chat_messages_away');try {const { status, body } = await ready();assert.strictEqual(status, 503);@@@ -90,9 +91,9 @@ t('database failing: 503 not_ready naming the db check', async () => {assert.strictEqual(body.status, 'not_ready');assert.deepStrictEqual(body.failed, ['db']);assert.strictEqual(body.checks.db.status, 'fail');-- assert.match(body.checks.db.error, /no such table/);++ assert.match(body.checks.db.error, /does not exist/);} finally {-- h.db.run('ALTER TABLE chat_messages_away RENAME TO chat_messages');++ await owner.query('ALTER TABLE chat_messages_away RENAME TO chat_messages');}const { status } = await ready();assert.strictEqual(status, 200);@@@ -113,9 +113,9 @@ t('DMs: groups, blocks, banned users, user search', async () => {t('chat history: global page and cursor delta, deleted and expired rows hidden', async () => {const ids = [];-- for (let i = 0; i < 5; i++) ids.push(Number(h.db.saveChatMessage({ stream_id: null, user_id: alice.id, username: 'Alice', message: `g${i}`, message_type: 'chat', is_global: true }).lastInsertRowid));-- h.db.deleteChatMessage(ids[1], staff.id);-- h.db.run("UPDATE chat_messages SET auto_delete_at = '2000-01-01 00:00:00' WHERE id = ?", [ids[2]]);++ for (let i = 0; i < 5; i++) ids.push(Number((await h.db.saveChatMessage({ stream_id: null, user_id: alice.id, username: 'Alice', message: `g${i}`, message_type: 'chat', is_global: true })).lastInsertRowid));++ await h.db.deleteChatMessage(ids[1], staff.id);++ await h.db.run("UPDATE chat_messages SET auto_delete_at = '2000-01-01 00:00:00' WHERE id = ?", [ids[2]]);const page = await h.http('GET', '/api/chat/global/history?limit=10');const got = page.body.messages.map((m) => m.message);assert.deepStrictEqual(got.filter((x) => /^g\d$/.test(x)), ['g0', 'g3', 'g4']);@@@ -131,20 +131,21 @@ t('chat history: global page and cursor delta, deleted and expired rows hidden',const small = await h.http('GET', `/api/chat/global/history?after_id=0&limit=1`);assert.strictEqual(small.body.complete, false, 'bigger gap than the limit → take a fresh page');// Channel filter by username (the owner of the source stream or the channel room).-- h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'in the stream', message_type: 'chat' });++ await h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'in the stream', message_type: 'chat' });const byChan = await h.http('GET', '/api/chat/global/history?username=streamer');assert.deepStrictEqual(byChan.body.messages.map((m) => m.message), ['in the stream']);assert.strictEqual(byChan.body.messages[0].stream_channel, 'streamer');// The stream and channel cursor reads name deletions in their room too.-- const inRoom = [0, 1].map((i) => Number(h.db.saveChatMessage({ stream_id: streamId, channel_user_id: streamer.id, user_id: bob.id, username: 'Bob', message: `s${i}`, message_type: 'chat' }).lastInsertRowid));-- h.db.deleteChatMessage(inRoom[0], staff.id);++ const inRoom = [];++ for (const i of [0, 1]) inRoom.push(Number((await h.db.saveChatMessage({ stream_id: streamId, channel_user_id: streamer.id, user_id: bob.id, username: 'Bob', message: `s${i}`, message_type: 'chat' })).lastInsertRowid));++ await h.db.deleteChatMessage(inRoom[0], staff.id);for (const p of [`/api/chat/${streamId}/history`, `/api/chat/${streamId}/history?scope=stream&`, `/api/chat/channel/${streamer.id}/history`]) {const d = await h.http('GET', `${p}${p.endsWith('&') ? '' : '?'}after_id=${inRoom[1]}`);assert.strictEqual(d.status, 200, p);assert.deepStrictEqual(d.body.deleted_ids, [inRoom[0]], p);}assert.deepStrictEqual((await h.http('GET', `/api/chat/channel/${streamer.id + 999}/history?after_id=${inRoom[1]}`)).body.deleted_ids, [], 'another channel');-- h.db.deleteChatMessage(inRoom[1], staff.id); // the log counts below are about 'in the stream'++ await h.db.deleteChatMessage(inRoom[1], staff.id); // the log counts below are about 'in the stream'});t('chat logs and search are gated like Live', async () => {@@@ -171,22 +172,22 @@ t('chat logs and search are gated like Live', async () => {});t('staff reading other people\'s logs is audited; your own chat exports; CSV cells are never formulas', async () => {-- const audits = () => h.db.all("SELECT action_type, actor_user_id, target_user_id FROM moderation_actions WHERE action_type LIKE 'chat_log_%' ORDER BY id");-- const before = audits().length;++ const audits = async () => await h.db.all("SELECT action_type, actor_user_id, target_user_id FROM moderation_actions WHERE action_type LIKE 'chat_log_%' ORDER BY id");++ const before = (await audits()).length;await h.http('GET', `/api/chat/search?q=stream&user_id=${alice.id}`, { token: alice.token });await h.http('GET', `/api/chat/user/${alice.id}/history`, { token: alice.token });await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}`, { token: streamer.token });-- assert.strictEqual(audits().length, before, 'your own lines and your own stream are not audited');++ assert.strictEqual((await audits()).length, before, 'your own lines and your own stream are not audited');await h.http('GET', `/api/chat/search?q=stream&user_id=${bob.id}`, { token: staff.token });await h.http('GET', `/api/chat/user/${bob.id}/history`, { token: staff.token });await h.http('GET', `/api/chat/user/${bob.id}/history?offset=50`, { token: staff.token });-- h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: '=HYPERLINK("x")', message_type: 'chat' });++ await h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: '=HYPERLINK("x")', message_type: 'chat' });const admin = h.addUser('exporter', { role: 'admin' });await h.ctx.sync();const ex = await h.http('GET', `/api/chat/admin/logs/export?streamId=${streamId}&format=csv`, { token: admin.token });assert.strictEqual(ex.status, 200);assert.ok(ex.text.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking message is text');-- const got = audits().slice(before);++ const got = (await audits()).slice(before);assert.deepStrictEqual(got.map((a) => a.action_type), ['chat_log_search', 'chat_log_view', 'chat_log_export'], 'search, first page viewed, export');assert.deepStrictEqual(got.map((a) => a.actor_user_id), [staff.id, staff.id, admin.id]);assert.deepStrictEqual(got.slice(0, 2).map((a) => a.target_user_id), [bob.id, bob.id]);@@@ -210,7 +211,7 @@ t('profile card and anon info come from Live', async () => {assert.ok('last_seen' in own.body);assert.strictEqual((await h.http('GET', '/api/chat/user/nobody/profile')).status, 404);h.live.anon.set('203.0.113.99', { num: 42, first_seen: '2026-01-01 00:00:00' });-- h.db.saveChatMessage({ stream_id: null, anon_id: 'anon42', username: 'anon42', message: 'hi', message_type: 'chat', is_global: true });++ await h.db.saveChatMessage({ stream_id: null, anon_id: 'anon42', username: 'anon42', message: 'hi', message_type: 'chat', is_global: true });const a = await h.http('GET', '/api/chat/anon/anon42');assert.deepStrictEqual({ ...a.body.anon, first_chat: !!a.body.anon.first_chat }, { anon_id: 'anon42', anon_num: 42, first_seen: '2026-01-01 00:00:00', first_chat: true, message_count: 1 });assert.strictEqual((await h.http('GET', '/api/chat/anon/bob')).status, 400);@@@ -13,12 +13,11 @@* real id — including a bridge broadcast whose insert was acknowledged before the restart).*/const assert = require('assert');--const Database = require('better-sqlite3');const path = require('path');const { boot, suite } = require('./helpers');const t = suite('restart-resume');--let h, chat, port, dbFile, streamer, posters, reader, channelId, streamId;++let h, chat, port, streamer, posters, reader, channelId, streamId;const REF = -(2 ** 40) - 1;const BOOT = 'live-boot-restart';let BRIDGE;@@@ -26,12 +25,9 @@ const sockets = [];const calls = (ops) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: BOOT, ops: ops.map((o, i) => ({ seq: i + 1, ...o })) } });--// What the database says the rooms hold (a separate read-only connection: Chat is another process).--function roomRows(where, params) {-- const d = new Database(dbFile, { readonly: true, fileMustExist: true });-- try {-- return d.prepare(`SELECT id, message, message_type FROM chat_messages WHERE is_deleted = 0 AND ${where} ORDER BY id`).all(...params);-- } finally { d.close(); }++// What the database says the rooms hold (this process's own handle on the same database: Chat is another process).++async function roomRows(where, params) {++ return await h.db.getDb().prepare(`SELECT id, message, message_type FROM chat_messages WHERE is_deleted = 0 AND ${where} ORDER BY id`).all(...params);}/** A reader as Live's chat.js keeps one: shown rows by id and the cursor (the highest id shown). */@@@ -78,9 +74,9 @@ async function post(user, ip, text) {return own.id;}--const streamRoom = { key: 'stream', rows: () => roomRows('channel_user_id = ?', [streamer.id]), delta: (after) => h.http('GET', `/api/chat/${streamId}/history?after_id=${after}`) };--const channelRoom = { key: 'channel', rows: () => roomRows('channel_user_id = ?', [streamer.id]), delta: (after) => h.http('GET', `/api/chat/channel/${streamer.id}/history?after_id=${after}`) };--const globalRoom = { key: 'global', rows: () => roomRows("message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation')", []), delta: (after) => h.http('GET', `/api/chat/global/history?after_id=${after}`) };++const streamRoom = { key: 'stream', rows: async () => await roomRows('channel_user_id = ?', [streamer.id]), delta: (after) => h.http('GET', `/api/chat/${streamId}/history?after_id=${after}`) };++const channelRoom = { key: 'channel', rows: async () => await roomRows('channel_user_id = ?', [streamer.id]), delta: (after) => h.http('GET', `/api/chat/channel/${streamer.id}/history?after_id=${after}`) };++const globalRoom = { key: 'global', rows: async () => await roomRows("message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation')", []), delta: (after) => h.http('GET', `/api/chat/global/history?after_id=${after}`) };const readers = {};@@@ -92,7 +88,6 @@ t('boot the stubs, then Chat as its own process', async () => {posters = ['ann', 'ben', 'cat', 'dan', 'eve', 'fay'].map((n) => h.addUser(n));channelId = h.addChannel(streamer.id);streamId = h.addStream(streamer.id, channelId);-- dbFile = path.join(h.tmp, 'chat.db');await h.detach();port = await h.freePort();chat = await h.spawnChat({ port });@@@ -136,7 +131,7 @@ t('the stream reader drops off; messages keep coming; Chat restarts (SIGTERM, ne// And one whose response Live never got: it will send it again after the restart.const lost = await calls([{ op: 'db', ref: REF - 3, key: 'live:r4', args: ['saveChatMessage', { stream_id: streamId, username: 'ChatBot', message: 'bridge retried', message_type: 'chat', source_platform: 'ai' }] }]);assert.ok(lost.body.results[0].ok, lost.text);-- beforeMax = roomRows('1 = 1', []).reduce((m, x) => Math.max(m, x.id), 0);++ beforeMax = (await roomRows('1 = 1', [])).reduce((m, x) => Math.max(m, x.id), 0);const closed = new Promise((r) => readers.global.ws.on('close', r));const exit = await h.stopChat(chat);@@@ -155,7 +150,7 @@ t('after the restart: the split broadcast of an acknowledged insert, a retried iconst late = await calls([{ op: 'forwardToGlobal', args: [streamId, { type: 'chat', id: REF - 1, username: 'ChatBot', message: 'bridge across restart' }] }]);assert.ok(late.body.results[0].ok, late.text);const frame = await readers.global.ws.next((m) => m.type === 'chat' && m.message === 'bridge across restart');-- assert.strictEqual(frame.id, roomRows("message = 'bridge across restart'", [])[0].id, 'the placeholder maps to the real id after a restart');++ assert.strictEqual(frame.id, (await roomRows("message = 'bridge across restart'", []))[0].id, 'the placeholder maps to the real id after a restart');// The insert whose answer was lost comes again with its key and its broadcast: applied once,// and the broadcast carries the first insert's id.@@@ -164,7 +159,7 @@ t('after the restart: the split broadcast of an acknowledged insert, a retried i{ op: 'forwardToGlobal', args: [streamId, { type: 'chat', id: REF - 3, username: 'ChatBot', message: 'bridge retried' }] },]);assert.ok(retry.body.results.every((x) => x.ok), retry.text);-- const once = roomRows("message = 'bridge retried'", []);++ const once = await roomRows("message = 'bridge retried'", []);assert.strictEqual(once.length, 1, 'a retried insert is applied once across the restart');assert.strictEqual(retry.body.results[0].result.lastInsertRowid, once[0].id);assert.strictEqual((await readers.global.ws.next((m) => m.type === 'chat' && m.message === 'bridge retried')).id, once[0].id);@@@ -198,7 +193,7 @@ for (const [name, room] of [['stream', streamRoom], ['global', globalRoom], ['chif (name === 'stream') await r.ws.next((m) => m.type === 'chat' && m.message === 'during reconnect');assert.deepStrictEqual(r.bad, [], `${name}: every frame carries its row's real id`);-- const want = room.rows();++ const want = await room.rows();assert.deepStrictEqual([...r.shown.keys()].sort((a, b) => a - b), want.map((x) => x.id), `${name}: exactly the room's rows`);const seen = new Set();for (const text of r.texts) { assert.ok(!seen.has(text), `${name}: "${text}" shown twice`); seen.add(text); }@@@ -36,7 +36,7 @@ t('boot', async () => {alice = h.addUser('alice', { subject: ids.newId('user') });bob = h.addUser('bob', { subject: ids.newId('user') });bot = h.addUser('helperbot', { subject: alice.subject_id, apiScopes: ['chat'] });-- for (const u of [alice, bob]) h.ctx.upsertUser(h.live.users.get(u.id));++ for (const u of [alice, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));assert.ok(require('../server/events/consumer').TOPICS.includes('network.user.token_valid_after'), 'Chat subscribes to it');});@@@ -45,7 +45,7 @@ t('a sign-out everywhere closes the person\'s socket within 5 seconds; others stconst b = await signedIn(tokenFor(bob));assert.strictEqual(a.auth.core_username, 'alice');const started = Date.now();-- const out = h.eventsConsumer.apply(event(alice.subject_id, Date.now() - 5000));++ const out = await h.eventsConsumer.apply(event(alice.subject_id, Date.now() - 5000));assert.strictEqual(out.outcome, 'revoked');const c = await closed(a.ws);assert.strictEqual(c.code, 4001);@@@ -64,8 +64,8 @@ t('the old token is refused afterwards; a newer one works; a redelivery and an oconst fresh = await signedIn(tokenFor(alice, 0));assert.strictEqual(fresh.auth.core_username, 'alice', 'a token issued after the cutoff works');const ev = event(alice.subject_id, Date.now() - 120000, 'password_changed');-- assert.strictEqual(h.eventsConsumer.apply(ev).outcome, 'unchanged', 'an older cutoff never moves it back');-- assert.strictEqual(h.eventsConsumer.apply(ev).duplicate, true);++ assert.strictEqual((await h.eventsConsumer.apply(ev)).outcome, 'unchanged', 'an older cutoff never moves it back');++ assert.strictEqual((await h.eventsConsumer.apply(ev)).duplicate, true);assert.strictEqual(fresh.ws.readyState, 1);fresh.ws.close();});@@@ -73,14 +73,14 @@ t('the old token is refused afterwards; a newer one works; a redelivery and an ot('bots on an API token and events from anyone but Network are left alone', async () => {const b = await signedIn(bot.token);assert.strictEqual(b.auth.core_username, 'helperbot');-- assert.strictEqual(h.eventsConsumer.apply(event(alice.subject_id, Date.now() + 1000)).outcome, 'revoked');++ assert.strictEqual((await h.eventsConsumer.apply(event(alice.subject_id, Date.now() + 1000))).outcome, 'revoked');await new Promise((r) => setTimeout(r, 200));assert.strictEqual(b.ws.readyState, 1, 'API-token socket stays');b.ws.close();const forged = { ...event(bob.subject_id, Date.now()), source: 'live' };-- assert.strictEqual(h.eventsConsumer.apply(forged).outcome, 'ignored:source');++ assert.strictEqual((await h.eventsConsumer.apply(forged)).outcome, 'ignored:source');const bad = event(bob.subject_id, Date.now()); bad.payload.valid_after = 'soon';-- assert.strictEqual(h.eventsConsumer.apply(bad).outcome, 'ignored:payload');++ assert.strictEqual((await h.eventsConsumer.apply(bad)).outcome, 'ignored:payload');});t.run(async () => { if (h && h.close) await h.close(); });@@@ -65,7 +65,7 @@ t('boot (CHAT_CALLS=1)', async () => {own = mk('owner1'); md = mk('mod1'); spk = mk('speaker1'); par = mk('part1'); mem = mk('member1'); vw = mk('viewer1');blk = mk('blocked1'); out = mk('outsider1'); staff = mk('staff1', { role: 'global_mod' }); banned = mk('banned1');bot = h.addUser('bot1', { apiScopes: ['chat'] });-- for (const u of [own, md, spk, par, mem, vw, blk, out, staff, banned, bot]) h.ctx.upsertUser(h.live.users.get(u.id));++ for (const u of [own, md, spk, par, mem, vw, blk, out, staff, banned, bot]) await h.ctx.upsertUser(h.live.users.get(u.id));});t('the matrix: what every role may do in every kind, public and private', async () => {@@@ -80,31 +80,31 @@ t('the matrix: what every role may do in every kind, public and private', asyncfor (const kind of ['community', 'call', 'system']) {for (const visibility of ['public', 'private']) {const owner = kind === 'system' ? staff : own;-- const room = rooms.bySlug(rooms.create(owner, { name: `M ${kind} ${visibility}`, kind, visibility }).slug);++ const room = await rooms.bySlug((await rooms.create(owner, { name: `M ${kind} ${visibility}`, kind, visibility })).slug);assert.deepStrictEqual(rooms.KIND_ROLES[kind], Object.keys(EXPECT[kind]), `${kind}: its roles`);-- for (const role of Object.keys(EXPECT[kind])) if (role !== 'owner') rooms.setRole(room, owner, byRole[role].id, role);++ for (const role of Object.keys(EXPECT[kind])) if (role !== 'owner') await rooms.setRole(room, owner, byRole[role].id, role);for (const [role, want] of Object.entries(EXPECT[kind])) {const who = role === 'owner' ? owner : byRole[role];-- const a = rooms.access(room, who);++ const a = await rooms.access(room, who);assert.strictEqual(a.role, role, `${kind}/${visibility}: ${who.username} is ${role}`);// A system room's owner is chat staff: they may post and manage anyway.assert.strictEqual(flags(a), want, `${kind}/${visibility}/${role}`);}const pub = visibility === 'public';const listen = kind === 'call' && pub ? 'j' : '-';-- assert.strictEqual(flags(rooms.access(room, out)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed in, no role`);-- assert.strictEqual(flags(rooms.access(room, null)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed out`);-- if (kind !== 'system') assert.strictEqual(flags(rooms.access(room, staff)), `r-${kind === 'call' ? 'jt' : '--'}mM`, `${kind}/${visibility}: chat staff without a role`);-- else assert.strictEqual(flags(rooms.access(room, staff)), 'rp--mM', 'system: staff post');++ assert.strictEqual(flags(await rooms.access(room, out)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed in, no role`);++ assert.strictEqual(flags(await rooms.access(room, null)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed out`);++ if (kind !== 'system') assert.strictEqual(flags(await rooms.access(room, staff)), `r-${kind === 'call' ? 'jt' : '--'}mM`, `${kind}/${visibility}: chat staff without a role`);++ else assert.strictEqual(flags(await rooms.access(room, staff)), 'rp--mM', 'system: staff post');}}// A site ban: reading stays, posting and talking go, whatever the role.-- const room = rooms.bySlug(rooms.create(own, { name: 'Banned Test', kind: 'call' }).slug);-- rooms.setRole(room, own, banned.id, 'speaker');-- assert.strictEqual(flags(rooms.access(room, banned)), 'rpjt--');++ const room = await rooms.bySlug((await rooms.create(own, { name: 'Banned Test', kind: 'call' })).slug);++ await rooms.setRole(room, own, banned.id, 'speaker');++ assert.strictEqual(flags(await rooms.access(room, banned)), 'rpjt--');h.live.addBan({ user_id: banned.id });await h.ctx.invalidateBans();-- assert.strictEqual(flags(rooms.access(room, banned)), 'r-j---', 'banned: reads and listens only');++ assert.strictEqual(flags(await rooms.access(room, banned)), 'r-j---', 'banned: reads and listens only');h.live.clearBans();await h.ctx.invalidateBans();});@@@ -140,7 +140,7 @@ t('roles belong to their kind, mods are the owner\'s, and joining gives the kindassert.strictEqual((await api('POST', '/open-stage/members', md, { username: 'member1', role: 'mod' })).status, 403, 'only the owner appoints mods');assert.strictEqual((await api('POST', '/open-stage/members', par, { username: 'member1', role: 'viewer' })).status, 403, 'a speaker is not a moderator');await api('POST', '/open-stage/members', md, { username: 'part1', role: 'participant' });-- const log = h.db.all("SELECT action_type, details FROM moderation_actions WHERE scope_type = 'room' AND scope_id = 'open-stage' ORDER BY id").map((x) => x.action_type);++ const log = (await h.db.all("SELECT action_type, details FROM moderation_actions WHERE scope_type = 'room' AND scope_id = 'open-stage' ORDER BY id")).map((x) => x.action_type);assert.deepStrictEqual(log, ['room_mod_add', 'room_speaker_add', 'room_speaker_remove']);// Posting follows the role: viewers and system-room viewers read only; staff post in system rooms.@@@ -237,8 +237,8 @@ t('a role change reaches the running call at once; a ban in the call blocks in tassert.strictEqual(spkWs.welcome.canTalk, true);ownWs.sendJson({ type: 'ban', targetPeerId: spkWs.welcome.peerId });await spkWs.closed;-- const room = rooms.bySlug('open-stage');-- assert.strictEqual(rooms.access(room, spk).role, 'blocked');++ const room = await rooms.bySlug('open-stage');++ assert.strictEqual((await rooms.access(room, spk)).role, 'blocked');assert.strictEqual((await api('GET', '/open-stage', spk)).status, 404, 'blocked: the room is gone for them');// Turning the room private drops people who are not members (a guest listening).@@@ -257,7 +257,7 @@ t('attachments: a manager attaches with their own token, idempotently; detach; nassert.deepStrictEqual([r.body.created, r.body.attachment.service, r.body.attachment.resource, r.body.room.slug], [true, 'community', 'general', 'tea-talk']);r = await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General' });assert.deepStrictEqual([r.status, r.body.created], [200, false], 'the same attachment again is a no-op');-- assert.strictEqual(h.db.get("SELECT COUNT(*) AS n FROM room_attachments WHERE resource = 'general'").n, 1);++ assert.strictEqual((await h.db.get("SELECT COUNT(*) AS n FROM room_attachments WHERE resource = 'general'")).n, 1);assert.strictEqual((await api('POST', '/tea-talk/attachments', mem, { service: 'community', resource: 'help' })).status, 403, 'members do not attach');assert.strictEqual((await api('POST', '/back-stage/attachments', out, { service: 'community', resource: 'help' })).status, 404, 'a private room stays missing');assert.strictEqual((await api('POST', '/tea-talk/attachments', bot, { service: 'community', resource: 'help' })).status, 403, 'API tokens do not attach');@@@ -18,7 +18,7 @@ t('boot', async () => {bob = h.addUser('bob', { subject: ids.newId('user') });cat = h.addUser('cat', { subject: ids.newId('user') });mod = h.addUser('staffmod', { subject: ids.newId('user'), role: 'global_mod' });-- for (const u of [ann, bob, cat, mod]) h.ctx.upsertUser(h.live.users.get(u.id));++ for (const u of [ann, bob, cat, mod]) await h.ctx.upsertUser(h.live.users.get(u.id));});t('create, list, join, post, history and unread', async () => {@@@ -87,7 +87,7 @@ t('moderation: delete and block, owner-only mods, logged; live delivery only toassert.strictEqual(r.body.role, 'blocked');await bobWs.next((m) => m.type === 'room_left');assert.strictEqual((await api('POST', '/night-owls/messages', bob, { message: 'let me back' })).status, 404, 'blocked: the room is gone for them');-- const logged = h.db.all("SELECT action_type FROM moderation_actions WHERE scope_type = 'room' ORDER BY id");++ const logged = await h.db.all("SELECT action_type FROM moderation_actions WHERE scope_type = 'room' ORDER BY id");assert.deepStrictEqual(logged.map((x) => x.action_type), ['delete_message', 'room_block']);for (const ws of [annWs, bobWs, outsider]) ws.close();});@@@ -111,15 +111,15 @@ t('slow mode and site bans', async () => {t('realtime plane (WS-I task 9): public rooms announce messages and redactions, private rooms never do', async () => {const { validate } = require('openvibe-contracts');const dan = h.addUser('dan', { subject: ids.newId('user') });-- h.ctx.upsertUser(h.live.users.get(dan.id));++ await h.ctx.upsertUser(h.live.users.get(dan.id));const db = require('../server/db/database');-- const since = (db.get('SELECT MAX(rowid) AS n FROM events_outbox') || {}).n || 0;-- const newEvents = () => db.all('SELECT event FROM events_outbox WHERE rowid > ? ORDER BY rowid', [since]).map((r) => JSON.parse(r.event)).filter((e) => /^chat\.room\./.test(e.event_type));++ const since = (await db.get('SELECT MAX(seq) AS n FROM events_outbox') || {}).n || 0;++ const newEvents = async () => (await db.all('SELECT event FROM events_outbox WHERE seq > ? ORDER BY seq', [since])).map((r) => JSON.parse(r.event)).filter((e) => /^chat\.room\./.test(e.event_type));let r = await api('POST', '/', dan, { name: 'Open Plaza' });assert.strictEqual(r.status, 201, r.text);r = await api('POST', '/open-plaza/messages', dan, { message: 'hello plaza' });const msgId = r.body.message.id;-- let ev = newEvents();++ let ev = await newEvents();assert.strictEqual(ev.length, 1);assert.deepStrictEqual([ev[0].event_type, ev[0].visibility, ev[0].subject], ['chat.room.message.created', 'public', { type: 'chat_room_message', id: String(msgId) }]);assert.strictEqual(ev[0].payload.room.slug, 'open-plaza'); assert.strictEqual(ev[0].payload.text, 'hello plaza');@@@ -130,13 +130,13 @@ t('realtime plane (WS-I task 9): public rooms announce messages and redactions,r = await api('POST', '/', dan, { name: 'Back Room', visibility: 'private' });assert.strictEqual(r.status, 201, r.text);await api('POST', '/back-room/messages', dan, { message: 'just us' });-- assert.strictEqual(newEvents().length, 1, 'no event for a private room');++ assert.strictEqual((await newEvents()).length, 1, 'no event for a private room');// Deleting a public message redacts it; turning the room private redacts the rest.await api('POST', '/open-plaza/messages', dan, { message: 'second' });await api('POST', '/open-plaza/messages', dan, { message: 'third' });assert.strictEqual((await api('DELETE', `/open-plaza/messages/${msgId}`, dan)).status, 200);-- ev = newEvents();++ ev = await newEvents();const del = ev.filter((e) => e.event_type === 'chat.room.message.deleted');assert.strictEqual(del.length, 1);assert.deepStrictEqual(del[0].payload.message_ids, [msgId]);@@@ -144,10 +144,10 @@ t('realtime plane (WS-I task 9): public rooms announce messages and redactions,assert.ok(validate('chat.room.message.deleted@1', del[0].payload).valid);r = await api('PATCH', '/open-plaza', dan, { visibility: 'private' });assert.strictEqual(r.status, 200, r.text);-- const last = newEvents().filter((e) => e.event_type === 'chat.room.message.deleted').pop();++ const last = (await newEvents()).filter((e) => e.event_type === 'chat.room.message.deleted').pop();assert.strictEqual(last.payload.message_ids.length, 2, 'the two remaining public messages are redacted');await api('POST', '/open-plaza/messages', dan, { message: 'now private' });-- assert.strictEqual(newEvents().filter((e) => e.event_type === 'chat.room.message.created').length, 3, 'nothing new once private');++ assert.strictEqual((await newEvents()).filter((e) => e.event_type === 'chat.room.message.created').length, 3, 'nothing new once private');});t.run(async () => { if (h && h.close) await h.close(); });@@@ -8,8 +8,19 @@* npm test -- ws import # only files whose name contains one of the words* npm test -- --strict # a skipped test fails the run too*++ * npm run test:pg # the same on PostgreSQL through PgBouncer, plus Valkey (OV_TEST_PG_URL,++ * # OV_TEST_PG_DIRECT_URL, OV_TEST_VALKEY_URL: openvibe-sdk scripts/test-services.sh up)++ *++ * Every test process gets one migrated database from test/helpers/pg-preload.mjs (node --import): an++ * in-memory PGlite, or with OV_TEST_STORE=pg a schema and roles of its own on the PostgreSQL server.++ ** A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with* ○ and not counted as passed (openvibe-shared/test-runner).*/'use strict';--require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 32, parallel: 1 });++const path = require('path');++const { pathToFileURL } = require('url');++require('openvibe-shared/test-runner').main({++ dir: __dirname, timeoutMs: 60000, pad: 32, parallel: 1,++ nodeArgs: ['--import', pathToFileURL(path.join(__dirname, 'helpers', 'pg-preload.mjs')).href],++});@@@ -24,8 +24,8 @@ const refused = (r, what) => assert.ok([401, 403, 404].includes(r.status), `${wht('boot and seed', async () => {h = await boot({ env: { CHAT_CALLS: '1', CHAT_WEB_URL: 'https://openvibe.chat' } });-- const mk = (name, opts = {}) => { const u = h.addUser(name, { subject: ids.newId('user'), ...opts }); h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); return u; };-- ann = mk('ann', { role: 'streamer' }); bob = mk('bob', { role: 'streamer' }); cat = mk('cat');++ const mk = async (name, opts = {}) => { const u = h.addUser(name, { subject: ids.newId('user'), ...opts }); await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); return u; };++ ann = await mk('ann', { role: 'streamer' }); bob = await mk('bob', { role: 'streamer' }); cat = await mk('cat');annStream = h.addStream(ann.id, h.addChannel(ann.id), { title: 'Ann live', is_live: 1 });bobStream = h.addStream(bob.id, h.addChannel(bob.id), { title: 'Bob live', is_live: 1 });@@@ -43,10 +43,10 @@ t('boot and seed', async () => {r = await api('POST', `/api/dm/conversations/${s.conv}/messages`, ann, { message: 'ann in the trio' });s.dmMsg = (r.body.message && r.body.message.id) || r.body.id;-- s.sound = Number(h.db.createChannelSound({ channel_owner_id: ann.id, command: 'annhorn', url: '/api/sounds/file/none.mp3', created_by: ann.id, created_by_name: 'ann' }).lastInsertRowid);++ s.sound = Number((await h.db.createChannelSound({ channel_owner_id: ann.id, command: 'annhorn', url: '/api/sounds/file/none.mp3', created_by: ann.id, created_by_name: 'ann' })).lastInsertRowid);const audioQueue = require('../server/chat/audio-queue');audioQueue.stop(); // nothing plays here: Ann's request stays queued, so any change to it is Bob's doing-- s.annReq = audioQueue.enqueue({ kind: 'tts', streamId: annStream, requestedBy: cat.id, label: 'hello', payload: { text: 'hello' } });++ s.annReq = await audioQueue.enqueue({ kind: 'tts', streamId: annStream, requestedBy: cat.id, label: 'hello', payload: { text: 'hello' } });s.annReqId = s.annReq && (s.annReq.id || (s.annReq.request && s.annReq.request.id));assert.ok(s.annReqId, JSON.stringify(s.annReq));});@@@ -57,15 +57,15 @@ const same = (a, b) => {const i = x.findIndex((v, k) => JSON.stringify(v) !== JSON.stringify(y[k]));assert.fail(`table #${i} changed:\n before ${JSON.stringify(x[i]).slice(0, 600)}\n after ${JSON.stringify(y[i]).slice(0, 600)}`);};--const snapshot = () => JSON.stringify([-- h.db.all('SELECT * FROM rooms ORDER BY id'), h.db.all('SELECT room_id, user_id, role FROM room_members ORDER BY room_id, user_id'),-- h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), h.db.all('SELECT * FROM room_attachments ORDER BY 1'),-- h.db.all('SELECT conversation_id, user_id FROM dm_participants ORDER BY conversation_id, user_id'), h.db.all('SELECT id, message FROM dm_messages ORDER BY id'),-- h.db.all('SELECT id, channel_owner_id, command FROM channel_sounds ORDER BY id'), h.db.all('SELECT id, state FROM audio_requests ORDER BY id'),++const snapshot = async () => JSON.stringify([++ await h.db.all('SELECT * FROM rooms ORDER BY id'), await h.db.all('SELECT room_id, user_id, role FROM room_members ORDER BY room_id, user_id'),++ await h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), await h.db.all('SELECT * FROM room_attachments ORDER BY 1'),++ await h.db.all('SELECT conversation_id, user_id FROM dm_participants ORDER BY conversation_id, user_id'), await h.db.all('SELECT id, message FROM dm_messages ORDER BY id'),++ await h.db.all('SELECT id, channel_owner_id, command FROM channel_sounds ORDER BY id'), await h.db.all('SELECT id, state FROM audio_requests ORDER BY id'),]);t('rooms: Bob, a member of Ann\'s room, cannot change it, delete her or Cat\'s messages, or hand out roles', async () => {-- const before = snapshot();++ const before = await snapshot();refused(await rooms('PATCH', `/${s.room}`, bob, { name: 'Bob Room', visibility: 'private', slow_seconds: 600 }), 'PATCH room');refused(await rooms('DELETE', `/${s.room}/messages/${s.annMsg}`, bob), 'delete Ann\'s message');refused(await rooms('DELETE', `/${s.room}/messages/${s.catMsg}`, bob), 'delete Cat\'s message');@@@ -74,27 +74,27 @@ t('rooms: Bob, a member of Ann\'s room, cannot change it, delete her or Cat\'s m}refused(await rooms('POST', `/${s.room}/attachments`, bob, { service: 'community', resource: 'bobs-space' }), 'attach');refused(await rooms('DELETE', `/${s.room}/attachments/community/anything`, bob), 'detach');-- same(before, snapshot());++ same(before, await snapshot());});t('group DM: Bob cannot delete Ann\'s message or remove Cat (only the creator removes others)', async () => {-- const before = snapshot();++ const before = await snapshot();refused(await api('DELETE', `/api/dm/conversations/${s.conv}/messages/${s.dmMsg}`, bob), 'delete Ann\'s DM');refused(await api('DELETE', `/api/dm/conversations/${s.conv}/participants/${cat.id}`, bob), 'remove Cat');refused(await api('DELETE', `/api/dm/conversations/${s.conv}/participants/${ann.id}`, bob), 'remove Ann');-- same(before, snapshot());++ same(before, await snapshot());});t('sounds: Bob cannot delete Ann\'s channel sound or rename her sound command', async () => {-- const before = snapshot();++ const before = await snapshot();refused(await api('DELETE', `/api/sounds/${s.sound}`, bob), 'delete sound');refused(await api('PATCH', '/api/sounds/command', bob, { channel_id: ann.id, command: 'annhorn', new_command: 'pwned', emote_code: 'x' }), 'rename command (channel)');refused(await api('PATCH', '/api/sounds/command', bob, { stream_id: annStream, command: 'annhorn', new_command: 'pwned' }), 'rename command (stream)');-- same(before, snapshot());++ same(before, await snapshot());});t('TTS/sound queue: Bob cannot read, skip, clear or report in Ann\'s room, nor reach her request through his own', async () => {-- const before = snapshot();++ const before = await snapshot();refused(await api('GET', `/api/tts/queue?stream_id=${annStream}`, bob), 'list Ann\'s queue');refused(await api('GET', `/api/tts/queue?channel_user_id=${ann.id}`, bob), 'list Ann\'s channel queue');refused(await api('POST', '/api/tts/queue/skip', bob, { stream_id: annStream, id: s.annReqId }), 'skip in Ann\'s room');@@@ -104,7 +104,7 @@ t('TTS/sound queue: Bob cannot read, skip, clear or report in Ann\'s room, nor rassert.ok([404, 409].includes(through.status), `Ann's request through Bob's room: ${through.status} ${through.text}`);const report = await api('POST', `/api/tts/queue/${s.annReqId}/report`, bob, { stream_id: bobStream, state: 'failed', error: 'x' });assert.ok([400, 403, 404, 409].includes(report.status), `report Ann's request from Bob's room: ${report.status} ${report.text}`);-- same(before, snapshot());++ same(before, await snapshot());});t('calls: Bob cannot change Ann\'s stream call settings or delete her voice channel', async () => {@@@ -119,9 +119,9 @@ t('calls: Bob cannot change Ann\'s stream call settings or delete her voice chan});t('moderation: Bob cannot purge Ann\'s stream chat', async () => {-- h.db.saveChatMessage({ stream_id: annStream, channel_user_id: ann.id, user_id: cat.id, username: 'cat', message: 'cat chats on ann' });-- const count = () => h.db.all('SELECT count(*) AS n FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0', [annStream])[0].n;-- const before = count();++ await h.db.saveChatMessage({ stream_id: annStream, channel_user_id: ann.id, user_id: cat.id, username: 'cat', message: 'cat chats on ann' });++ const count = async () => (await h.db.all('SELECT count(*) AS n FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0', [annStream]))[0].n;++ const before = await count();assert.ok(before >= 1);const range = { from: '2000-01-01T00:00:00Z', to: '2100-01-01T00:00:00Z' };for (const key of ['streamId', 'stream_id']) {@@@ -130,7 +130,7 @@ t('moderation: Bob cannot purge Ann\'s stream chat', async () => {const p = await api('POST', '/api/chat/admin/purge/preview', bob, { [key]: annStream, ...range });if (key === 'streamId') refused(p, 'preview Ann\'s stream');}-- assert.strictEqual(count(), before, 'Ann\'s chat is intact');++ assert.strictEqual(await count(), before, 'Ann\'s chat is intact');});t('preferences: a body naming Ann writes Bob\'s own record only', async () => {@@@ -38,7 +38,7 @@ t('boot and seed: a private room, a private call room, a DM', async () => {cat = h.addUser('cat', { subject: ids.newId('user') });dan = h.addUser('dan', { subject: ids.newId('user') });mod = h.addUser('staffmod', { subject: ids.newId('user'), role: 'global_mod' });-- for (const u of [ann, bob, cat, dan, mod]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }++ for (const u of [ann, bob, cat, dan, mod]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }let r = await rooms('POST', '/', ann, { name: SECRET.roomName, visibility: 'private', topic: SECRET.topic });assert.strictEqual(r.status, 201, r.text);@@@ -122,12 +122,12 @@ t('WebSocket: a stranger cannot join the private room, and hears nothing postedt('events outbox: nothing about a private room or a DM leaves Chat', async () => {// (The staff member's own log searches above are audited with the words they typed: their query, not the room's content.)-- const rows = JSON.stringify(h.db.all('SELECT * FROM events_outbox').filter((r) => !/"action_type":"chat_log_search"/.test(r.event)));++ const rows = JSON.stringify((await h.db.all('SELECT * FROM events_outbox')).filter((r) => !/"action_type":"chat_log_search"/.test(r.event)));for (const w of ['live-private-words', 'live-dm-words', SECRET.roomWords, SECRET.callWords, SECRET.dmWords, SECRET.roomName, SECRET.topic]) assert.ok(!rows.includes(w), `outbox carries "${w}"`);});t('ids swapped into the stranger\'s own room reach nothing of the private room', async () => {-- const before = h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id');++ const before = await h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id');let r = await rooms('DELETE', `/${seeded.open}/messages/${seeded.msg}`, cat);assert.strictEqual(r.status, 404, r.text);r = await rooms('DELETE', `/${seeded.open}/messages/${seeded.callMsg}`, cat);@@@ -137,14 +137,14 @@ t('ids swapped into the stranger\'s own room reach nothing of the private room',assert.ok(!r.text.includes(SECRET.roomWords) && !r.text.includes(SECRET.callWords));r = await dms('DELETE', `/conversations/${seeded.conv}/messages/${seeded.dmMsg}`, cat);assert.ok([403, 404].includes(r.status), r.text);-- assert.deepStrictEqual(h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), before);++ assert.deepStrictEqual(await h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), before);assert.ok((await dms('GET', `/conversations/${seeded.conv}/messages`, ann)).text.includes(SECRET.dmWords), 'the DM is intact');});t('a stranger\'s every write to the private room or the DM is refused and changes nothing', async () => {-- const snap = () => JSON.stringify([h.db.all('SELECT * FROM rooms ORDER BY id'), h.db.all('SELECT room_id, user_id, role FROM room_members ORDER BY room_id, user_id'),-- h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), h.db.all('SELECT * FROM dm_participants ORDER BY conversation_id, user_id')]);-- const before = snap();++ const snap = async () => JSON.stringify([await h.db.all('SELECT * FROM rooms ORDER BY id'), await h.db.all('SELECT room_id, user_id, role FROM room_members ORDER BY room_id, user_id'),++ await h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), await h.db.all('SELECT * FROM dm_participants ORDER BY conversation_id, user_id')]);++ const before = await snap();for (const [m, p, body] of [['PATCH', `/${seeded.slug}`, { name: 'pwned', visibility: 'public' }], ['POST', `/${seeded.slug}/join`], ['POST', `/${seeded.slug}/messages`, { message: 'x' }],['DELETE', `/${seeded.slug}/messages/${seeded.msg}`], ['POST', `/${seeded.slug}/members`, { username: 'cat', role: 'mod' }], ['POST', `/${seeded.slug}/read`],@@@ -167,7 +167,7 @@ t('a stranger\'s every write to the private room or the DM is refused and changeassert.ok(!r.text.includes(SECRET.dmWords), `${m} ${p} carries the DM`);}}-- assert.strictEqual(snap(), before);++ assert.strictEqual(await snap(), before);});t.run(() => h && h.close());@@@ -40,10 +40,10 @@ t('boot with a sentinel in every secret', async () => {h = await boot({ env: { ...ENV, CHAT_WEB_URL: 'https://openvibe.chat' } });Object.assign(h.live.settings, SETTINGS, { tts_enabled: true, tts_provider: 'google' });await h.ctx.ensureSettings(true);-- const mk = (name, opts = {}) => { const u = h.addUser(name, { subject: ids.newId('user'), ...opts }); h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); return u; };++ const mk = async (name, opts = {}) => { const u = h.addUser(name, { subject: ids.newId('user'), ...opts }); await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); return u; };people = {-- anonymous: null, viewer: mk('viewer'), streamer: mk('streamer', { role: 'streamer' }), 'global mod': mk('staffmod', { role: 'global_mod' }),-- admin: mk('admin', { role: 'admin' }), 'site owner': mk('owner', { role: 'admin', is_owner: 1 }),++ anonymous: null, viewer: await mk('viewer'), streamer: await mk('streamer', { role: 'streamer' }), 'global mod': await mk('staffmod', { role: 'global_mod' }),++ admin: await mk('admin', { role: 'admin' }), 'site owner': await mk('owner', { role: 'admin', is_owner: 1 }),};people.stream = h.addStream(people.streamer.id, h.addChannel(people.streamer.id), { title: 'Live now', is_live: 1 });});@@@ -113,7 +113,7 @@ t('internal routes with a wrong, missing or other-audience service token, and th});t('the events outbox and the log lines carry no secret', async () => {-- const rows = JSON.stringify(h.db.all('SELECT * FROM events_outbox'));++ const rows = JSON.stringify(await h.db.all('SELECT * FROM events_outbox'));for (const [k, v] of Object.entries({ ...ENV, ...SETTINGS })) {assert.ok(!rows.includes(v), `outbox carries ${k}`);const line = logs.find((l) => l.includes(v));@@@ -87,7 +87,7 @@ t('/me, /clear and ban notices from global chat or a channel room stay in that r});t('/me and /tts obey followers-only and the slur filter', async () => {-- h.setChannelSettings(chanX, { followers_only: 1, slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });++ await h.setChannelSettings(chanX, { followers_only: 1, slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });const viewer = await joined({ ip: '198.51.100.20', token: streamer.token, stream: streamX }, { streamId: streamX });const bobWs = await joined({ ip: '198.51.100.21', token: bob.token, stream: streamX }, { streamId: streamX });bobWs.sendJson({ type: 'chat', message: 'plain line' });@@@ -99,7 +99,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {assert.ok(await viewer.none((m) => /sneaks past/.test(m.message || ''), 500), '/me or /tts bypassed followers-only');h.live.follows.set(bob.id, [streamer.id]);-- h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });++ await h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });const bob2 = await joined({ ip: '198.51.100.22', token: bob.token, stream: streamX }, { streamId: streamX });bob2.sendJson({ type: 'chat', message: '/me says bannedword' });await bob2.next((m) => m.type === 'slur-blocked');@@@ -107,7 +107,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {bob2.sendJson({ type: 'chat', message: '/tts bannedword' });await bob2.next((m) => m.type === 'slur-blocked');assert.ok(await viewer.none((m) => /bannedword/.test(m.message || ''), 300), 'slur filter bypassed by /me or /tts');-- h.setChannelSettings(chanX, null);++ await h.setChannelSettings(chanX, null);for (const w of [viewer, bobWs, bob2]) w.close();});@@@ -124,21 +124,21 @@ t('joining the channel room without the stream id does not skip slow mode or IPassert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');// (/slow 30 is the channel's saved setting now; the policy set below replaces it)-- h.setChannelSettings(chanX, { ip_approval_mode: 1 });++ await h.setChannelSettings(chanX, { ip_approval_mode: 1 });const fresh = await joined({ ip: '198.51.100.32' }, { channelUserId: streamer.id });fresh.sendJson({ type: 'chat', message: 'unapproved hello' });await fresh.next((m) => m.type === 'system' && /IP approval mode/.test(m.message));assert.ok(await own.none((m) => m.message === 'unapproved hello'), 'IP approval bypassed from the channel room');-- assert.ok(h.db.get("SELECT 1 FROM pending_ip_messages WHERE message = 'unapproved hello'"), 'held for review');++ assert.ok(await h.db.get("SELECT 1 FROM pending_ip_messages WHERE message = 'unapproved hello'"), 'held for review');await h.sleep(1100);fresh.sendJson({ type: 'chat', message: '/me unapproved action' });assert.ok(await own.none((m) => /unapproved action/.test(m.message || ''), 400), '/me bypassed IP approval');-- h.setChannelSettings(chanX, null);++ await h.setChannelSettings(chanX, null);for (const w of [own, sneaky, fresh]) w.close();});t('a catastrophic custom slur regex cannot freeze the chat server', async () => {-- h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_use_builtin: 0, slur_filter_regexes: '^(a|a)*$' });++ await h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_use_builtin: 0, slur_filter_regexes: '^(a|a)*$' });const viewer = await joined({ ip: '198.51.100.40', token: streamer.token, stream: streamX }, { streamId: streamX });const bobWs = await joined({ ip: '198.51.100.41', token: bob.token, stream: streamX }, { streamId: streamX });const line = 'a'.repeat(30) + 'b';@@@ -146,7 +146,7 @@ t('a catastrophic custom slur regex cannot freeze the chat server', async () =>bobWs.sendJson({ type: 'chat', message: line });await viewer.next((m) => m.type === 'chat' && m.message === line, 20000);assert.ok(Date.now() - started < 1500, `chat blocked for ${Date.now() - started}ms by a streamer regex`);-- h.setChannelSettings(chanX, null);++ await h.setChannelSettings(chanX, null);for (const w of [viewer, bobWs]) w.close();});@@@ -49,7 +49,7 @@ t('upload a channel sound: converted to MP3 in the shared sounds dir, listed, sesoundUrl = r.body.sound.url;assert.strictEqual(r.body.sound.command, 'honk');assert.match(soundUrl, /^\/api\/sounds\/file\/snd-.*\.mp3$/);-- const row = h.db.getChannelSoundById(soundId);++ const row = await h.db.getChannelSoundById(soundId);assert.ok(row.url.startsWith(path.resolve(process.env.SOUNDS_PATH)) && fs.existsSync(row.url));await h.sleep(150);assert.ok(h.live.effects.some((e) => e.name === 'asset-sync' && e.body.op === 'syncSoon'), 'Live mirrors it to Media');@@@ -74,25 +74,25 @@ t('!honk in the stream chat plays it (rich announce + audio), persisted', asyncconst audio = await ws.next((m) => m.type === 'soundboard-audio');assert.strictEqual(audio.title, '!honk');assert.ok(audio.audio.length > 100);-- assert.strictEqual(h.db.getChatMessageById(ann.id).message_type, 'channel-sound');++ assert.strictEqual((await h.db.getChatMessageById(ann.id)).message_type, 'channel-sound');ws.close();});t('delete: not someone else’s sound; the uploader can; Live drops the Media copy first', async () => {assert.strictEqual((await h.http('DELETE', `/api/sounds/${soundId}`, { token: other.token })).status, 403);-- const row = h.db.getChannelSoundById(soundId);++ const row = await h.db.getChannelSoundById(soundId);const del = await h.http('DELETE', `/api/sounds/${soundId}`, { token: viewer.token });assert.deepStrictEqual(del.body, { message: 'Sound deleted' });assert.ok(!fs.existsSync(row.url));assert.ok(h.live.effects.some((e) => e.name === 'asset-sync' && e.body.op === 'remove-sound' && e.body.asset_id === soundId));-- assert.strictEqual(h.db.getChannelSoundById(soundId), undefined);++ assert.strictEqual(await h.db.getChannelSoundById(soundId), undefined);});t('alert sounds: the streamer’s own channel, stored on Chat’s own settings row (C-04)', async () => {const r = await upload('/api/sounds/alert/donation', streamer.token, {}, wav());assert.strictEqual(r.status, 200, JSON.stringify(r.body));assert.strictEqual(r.body.kind, 'donation');-- const row = h.db.get('SELECT donation_sound_url, donation_sound_mime FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);++ const row = await h.db.get('SELECT donation_sound_url, donation_sound_mime FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);assert.ok(row.donation_sound_url.startsWith(path.resolve(process.env.SOUNDS_PATH)));assert.strictEqual(row.donation_sound_mime, 'audio/mpeg');});@@@ -28,33 +28,33 @@ t('boot', async () => {t('the folded-in account becomes the survivor in Chat', async () => {const d = require('../server/db/database').getDb();// Messages: in otto's channel and in fold's own channel.-- d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'fold', 'hi otto', ?, ?), (?, 'otto', 'hi fold', ?, ?)")++ await d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'fold', 'hi otto', ?, ?), (?, 'otto', 'hi fold', ?, ?)").run(fold.id, other.id, fold.subject_id, other.id, fold.id, other.subject_id);// DMs: fold↔otto (only fold), and a group where both keep and fold are.const dmFO = (await h.http('POST', '/api/dm/conversations', { token: fold.token, body: { user_ids: [other.id] } })).body.conversation.id;const group = (await h.http('POST', '/api/dm/conversations', { token: other.token, body: { user_ids: [keep.id, fold.id], name: 'Trio' } })).body.conversation.id;assert.strictEqual((await h.http('POST', `/api/dm/conversations/${dmFO}/messages`, { token: fold.token, body: { message: 'yo' } })).status < 300, true);-- d.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?), (?, ?, ?)').run(fold.id, other.id, fold.subject_id, fold.id, keep.id, fold.subject_id);++ await d.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?), (?, ?, ?)').run(fold.id, other.id, fold.subject_id, fold.id, keep.id, fold.subject_id);require('../server/chat/network-blocks').ensureSchema && require('../server/chat/network-blocks').ensureSchema();-- d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(fold.subject_id, other.subject_id, fold.subject_id, keep.subject_id);-- d.prepare("INSERT INTO moderation_actions (action_type, actor_subject_id) VALUES ('timeout', ?)").run(fold.subject_id);++ await d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(fold.subject_id, other.subject_id, fold.subject_id, keep.subject_id);++ await d.prepare("INSERT INTO moderation_actions (action_type, actor_subject_id) VALUES ('timeout', ?)").run(fold.subject_id);-- assert.strictEqual(h.eventsConsumer.apply(merged(fold.subject_id, keep.subject_id, { source: 'live' })).outcome, 'ignored:source');-- assert.strictEqual(h.eventsConsumer.apply({ ...merged(fold.subject_id, keep.subject_id), payload: { merge_id: 'nope' } }).outcome, 'ignored:payload');++ assert.strictEqual((await h.eventsConsumer.apply(merged(fold.subject_id, keep.subject_id, { source: 'live' }))).outcome, 'ignored:source');++ assert.strictEqual((await h.eventsConsumer.apply({ ...merged(fold.subject_id, keep.subject_id), payload: { merge_id: 'nope' } })).outcome, 'ignored:payload');const ev = merged(fold.subject_id, keep.subject_id);-- assert.strictEqual(h.eventsConsumer.apply(ev).outcome, 'merged');++ assert.strictEqual((await h.eventsConsumer.apply(ev)).outcome, 'merged');-- const q = (sql, ...a) => d.prepare(sql).all(...a);-- assert.deepStrictEqual(q('SELECT user_id AS u, channel_user_id AS c, subject_id AS s FROM chat_messages ORDER BY id').map((m) => [m.u, m.c, m.s]),++ const q = async (sql, ...a) => await d.prepare(sql).all(...a);++ assert.deepStrictEqual((await q('SELECT user_id AS u, channel_user_id AS c, subject_id AS s FROM chat_messages ORDER BY id')).map((m) => [m.u, m.c, m.s]),[[keep.id, other.id, keep.subject_id], [other.id, keep.id, other.subject_id]], 'authorship and the channel follow');-- assert.ok(q('SELECT sender_id, sender_subject_id FROM dm_messages').every((m) => m.sender_id === keep.id && m.sender_subject_id === keep.subject_id));-- assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', dmFO).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y));-- assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', group).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y), 'in the group once');-- assert.deepStrictEqual(q('SELECT blocker_id AS a, blocked_id AS b FROM dm_blocks').map((x) => [x.a, x.b]), [[keep.id, other.id]], 'moved; blocking oneself gone');-- assert.deepStrictEqual(q('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks').map((x) => [x.a, x.b]), [[keep.subject_id, other.subject_id]]);-- assert.deepStrictEqual(q('SELECT actor_subject_id AS a FROM moderation_actions').map((x) => x.a), [fold.subject_id], 'moderation history stays as recorded');++ assert.ok((await q('SELECT sender_id, sender_subject_id FROM dm_messages')).every((m) => m.sender_id === keep.id && m.sender_subject_id === keep.subject_id));++ assert.deepStrictEqual((await q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', dmFO)).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y));++ assert.deepStrictEqual((await q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', group)).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y), 'in the group once');++ assert.deepStrictEqual((await q('SELECT blocker_id AS a, blocked_id AS b FROM dm_blocks')).map((x) => [x.a, x.b]), [[keep.id, other.id]], 'moved; blocking oneself gone');++ assert.deepStrictEqual((await q('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks')).map((x) => [x.a, x.b]), [[keep.subject_id, other.subject_id]]);++ assert.deepStrictEqual((await q('SELECT actor_subject_id AS a FROM moderation_actions')).map((x) => x.a), [fold.subject_id], 'moderation history stays as recorded');// The inbox makes a redelivery a no-op.-- assert.strictEqual(h.eventsConsumer.apply(ev).duplicate, true);++ assert.strictEqual((await h.eventsConsumer.apply(ev)).duplicate, true);});t.run(async () => { if (h && h.close) await h.close(); });@@@ -63,7 +63,7 @@ async function startVip(keyOf) {const callsFor = (subject) => vip.state.calls.filter((c) => c.subject === subject).length;async function until(pred, ms = 3000) {const end = Date.now() + ms;-- while (!pred()) { if (Date.now() > end) throw new Error('timed out'); await new Promise((r) => setTimeout(r, 10)); }++ while (!await pred()) { if (Date.now() > end) throw new Error('timed out'); await new Promise((r) => setTimeout(r, 10)); }}async function joinRoom(user, ip) {const ws = await h.ws({ ip, token: user.token, stream: streamId });@@@ -112,7 +112,7 @@ t('a member\'s message carries the creator\'s badge (looked up at join, so the fawait new Promise((r) => setTimeout(r, 50));const { msg } = await say(memberWs, watcher);assert.deepStrictEqual(msg.vip_badge, { creator: CREATOR, perk: 'subscriber-badge', name: 'Subscriber badge', badge: 'subscriber' });-- const row = h.db.getChatMessageById(msg.id);++ const row = await h.db.getChatMessageById(msg.id);assert.deepStrictEqual(JSON.parse(row.metadata).vip_badge, msg.vip_badge);assert.ok(h.tokenRequests.some((r) => r.audience === 'openvibe.vip' && r.scope === 'vip.entitlement.check'), 'Chat asked the Network for a VIP token');const before = callsFor(MEMBER);@@@ -147,7 +147,7 @@ t('sending never waits on VIP: a slow lookup sends at once, the badge follows asconst follow = await watcher.next((m) => m.type === 'chat_vip_badge' && m.id === msg.id);// Creator-written binding config is cleaned: an unknown badge id becomes "member", the label is plain text.assert.deepStrictEqual(follow.vip_badge, { creator: CREATOR, perk: 'subscriber-badge', name: 'Subscriber badge', badge: 'member', label: 'bCrew/b' });-- await until(() => { const r = h.db.getChatMessageById(msg.id); return r.metadata && JSON.parse(r.metadata).vip_badge; });++ await until(async () => { const r = await h.db.getChatMessageById(msg.id); return r.metadata && JSON.parse(r.metadata).vip_badge; });const next = await say(ws, watcher);assert.strictEqual(next.msg.vip_badge.badge, 'member', 'cached from then on');});@@@ -25,7 +25,7 @@ t('boot', async () => {alice = h.addUser('alice', { subject: ids.newId('user') });bob = h.addUser('bob', { subject: ids.newId('user') });carol = h.addUser('carol', { subject: ids.newId('user') });-- for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }++ for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }});t('global chat reads without JavaScript; signing in shows the composer', async () => {@@@ -22,10 +22,10 @@ t('boot Chat against a stub Live', async () => {bob = h.addUser('bob');admin = h.addUser('boss', { role: 'admin' });channelId = h.addChannel(streamer.id, { moderators: [mod.id] });-- h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)++ await h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)streamId = h.addStream(streamer.id, channelId, { managed: { id: 71, slug: 'main-slot', title: 'Main slot' } });await h.ctx.sync();-- assert.ok(h.ctx.getStreamById(streamId), 'stream projected');++ assert.ok(await h.ctx.getStreamById(streamId), 'stream projected');});t('anonymous connect, join global → auth as an anon number from Live', async () => {@@@ -91,12 +91,12 @@ t('chat into a stream room: room delivery, global cross-feed, persistence, outbo// First chat in the channel → welcome line to the room.await bobWs.next((m) => m.type === 'system' && /^Welcome Alice to the chat!/.test(m.message));// Persisted with Live's ids and the Network subject.-- const row = h.db.getChatMessageById(msgId);++ const row = await h.db.getChatMessageById(msgId);assert.strictEqual(row.user_id, alice.id);assert.strictEqual(row.channel_user_id, streamer.id);assert.strictEqual(row.subject_id, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');// Outbox envelope in the same transaction.-- const ev = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.message.created'").map((r) => JSON.parse(r.event)).find((e) => e.payload.message_id === msgId);++ const ev = (await h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.message.created'")).map((r) => JSON.parse(r.event)).find((e) => e.payload.message_id === msgId);assert.ok(ev, 'chat.message.created in the outbox');assert.strictEqual(ev.visibility, 'public');assert.deepStrictEqual(ev.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });@@@ -168,12 +168,12 @@ t('DM delivery keeps the participant check (REST send → live socket; a non-parconst eveWs = await h.ws({ ip: '198.51.100.30', token: eve.token });eveWs.sendJson({ type: 'join', token: eve.token });await eveWs.next((m) => m.type === 'auth');-- h.chatServer.sendDm(eve.id, { type: 'dm', conversation_id: convId, message: { message: 'leak' } });++ await h.chatServer.sendDm(eve.id, { type: 'dm', conversation_id: convId, message: { message: 'leak' } });assert.ok(await eveWs.none((m) => m.type === 'dm'), 'participant check held');assert.strictEqual((await h.http('GET', `/api/dm/conversations/${convId}/messages`, { token: eve.token })).status, 403);// Offline notification went to Live; the DM event is subject-scoped and carries no text.assert.ok(h.live.effects.some((e) => e.name === 'notify/dm' && e.body.recipient_ids.includes(bob.id)));-- const ev = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.dm.created'").map((r) => JSON.parse(r.event)).pop();++ const ev = (await h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.dm.created'")).map((r) => JSON.parse(r.event)).pop();assert.strictEqual(ev.visibility, 'subject');assert.ok(!JSON.stringify(ev).includes('secret hello'), 'no DM text in the event');eveWs.close();@@@ -225,12 +225,12 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asyassert.strictEqual((await aliceWs.next((m) => m.type === 'slowmode')).seconds, 5);await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow mode enabled: 5s between messages');await h.sleep(100);-- assert.strictEqual(h.ctx.getChannelModerationSettings(channelId).slow_mode_seconds, 5, 'saved on Chat’s own row');-- assert.strictEqual(h.chatServer.slowModeByStream.get(streamId), 5000);++ assert.strictEqual((await h.ctx.getChannelModerationSettings(channelId)).slow_mode_seconds, 5, 'saved on Chat’s own row');++ assert.strictEqual((await h.chatServer.slowModeByStream()).get(streamId), 5000);// Every action is logged (and announced as an internal moderation event).-- const actions = h.db.all('SELECT action_type FROM moderation_actions ORDER BY id').map((r) => r.action_type);++ const actions = (await h.db.all('SELECT action_type FROM moderation_actions ORDER BY id')).map((r) => r.action_type);for (const a of ['channel_ban', 'channel_unban', 'channel_timeout', 'slowmode_update', 'clear_chat']) assert.ok(actions.includes(a), a);-- const modEv = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'").map((r) => JSON.parse(r.event));++ const modEv = (await h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'")).map((r) => JSON.parse(r.event));assert.ok(modEv.length >= 5 && modEv.every((e) => e.visibility === 'internal'));aliceWs.sendJson({ type: 'chat', message: 'slow one' });await bobWs.next((m) => m.type === 'chat' && m.message === 'slow one');
@@ -44,23 +44,28 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
@@ -44,23 +44,28 @@ before changing behaviour.** Browser JavaScript does not change; nginx routes th
channel moderation settings and moderators, bans and IP rules, follows, cosmetics and tags, site
channel moderation settings and moderators, bans and IP rules, follows, cosmetics and tags, site
settings and anon numbers are read through it; coins, AI viewers, arena, media queue, hardware,
settings and anon numbers are read through it; coins, AI viewers, arena, media queue, hardware,
pastes, translation, PowerChat, notifications, viewer counts and the IP log are effects it asks
pastes, translation, PowerChat, notifications, viewer counts and the IP log are effects it asks
- Live for. Reads are cached projections (SQLite `ctx_*` tables kept complete by paged syncs; TTL
+ Live for. Reads are cached projections (`ctx_*` tables kept complete by paged syncs; TTL
caches that serve stale while refreshing) warmed when a socket joins, so a chat message never
caches that serve stale while refreshing) warmed when a socket joins, so a chat message never
waits on a network read. Live pushes invalidations when it changes cached data.
waits on a network read. Live pushes invalidations when it changes cached data.
- **Live's side** is `docs/live-patch.diff` (applies to Live `main` with `git apply`):
- **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
`/internal/chat-context/*` and `/internal/chat-effects/*`, and `CHAT_AUTHORITY=chat`, which stops
Live's chat server and routes and turns `require('./chat/chat-server')` into a proxy that forwards
Live's chat server and routes and turns `require('./chat/chat-server')` into a proxy that forwards
Live's own chat calls here (`POST /internal/live/calls`).
Live's own chat calls here (`POST /internal/live/calls`).
-- **Own database** (`CHAT_DB_PATH` today, systemd `StateDirectory=openvibe-chat`) with Live's tables and
- ids, the Network subject on new rows, a transactional events outbox and a read mirror back into Live.
- The move to PostgreSQL (ADR-035, plan T3) is prepared here: `migrations/0001_initial.sql` is the whole
- schema on PostgreSQL (with the twelve Live read-mirror triggers, gated on `ov.mirror_skip`), and
- `scripts/import-sqlite-to-pg.js` moves the SQLite data over with a per-table report. In production that
- needs `DATABASE_URL` (runtime, through PgBouncer) and `DATABASE_DIRECT_URL` (the owner, for migrations);
- `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters on Valkey instead of this process.
+- **Own database on PostgreSQL** (ADR-035, plan T3) with Live's tables and ids, the Network subject on new
+ rows, a transactional events outbox and a read mirror back into Live. `server/db/database.js` serves
+ through `openvibe-sdk/db`: production needs `DATABASE_URL` (the runtime role, DML only, through
+ PgBouncer) and `DATABASE_DIRECT_URL` (the owner, which applies `migrations/` at boot); without
+ `DATABASE_URL` outside production it runs on an embedded PGlite in `data/pglite` (`CHAT_PGLITE_DIR`).
+ `migrations/0001_initial.sql` is the whole schema (timestamps stay SQLite-format text through
+ `ov_now()`/`datetime()`); its triggers on the twelve Chat tables queue Live's read mirror
+ (`live_mirror_outbox`), skipped where a transaction sets `ov.mirror_skip = '1'` (the importers).
+ `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
- **Import:** `scripts/import-from-live.js --live-db <snapshot> [--apply]` — a dry run unless
- `--apply` (which backs Chat's database up first), idempotent, never drops a row of Chat's tables
- (`import_hold`), reports counts per table.
+ `--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.
- **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
- **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
`chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their only
`chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their only
writer, there is no authority switch any more. Live's current release's writers still call Chat over
writer, there is no authority switch any more. Live's current release's writers still call Chat over
@@ -160,7 +165,7 @@ Chat subscribes (consumer `chat`) to these topics (and `network.user.token_valid
@@ -160,7 +165,7 @@ Chat subscribes (consumer `chat`) to these topics (and `network.user.token_valid
crash are the same card. A redeploy of a head already announced (a rollback to it) says nothing.
crash are the same card. A redeploy of a head already announced (a rollback to it) says nothing.
A release event older than 6 hours (an operator replay) is `ignored:stale`.
A release event older than 6 hours (an operator replay) is `ignored:stale`.
- **Exactly once.** The openvibe-sdk inbox (`chat_event_inbox`, pruned after 35 days) claims
- **Exactly once.** The openvibe-sdk inbox (`chat_event_inbox`, pruned after 35 days) claims
- `(chat, event_id)` in the same SQLite transaction as the change, and broadcasts run only after it
+ `(chat, event_id)` in the same transaction as the change, and broadcasts run only after it
commits. A failure answers 500 and rolls both back; Events retries.
commits. A failure answers 500 and rolls both back; Events retries.
- **Signature v2 only** (`parseDelivery` with `requireV2`): HMAC over `"<t>.<raw body>"` under
- **Signature v2 only** (`parseDelivery` with `requireV2`): HMAC over `"<t>.<raw body>"` under
`CHAT_EVENTS_SECRET`, `t` within ±300 s. A bad, stale, v1-only or unsigned delivery is 401; no secret
`CHAT_EVENTS_SECRET`, `t` within ±300 s. A bad, stale, v1-only or unsigned delivery is 401; no secret
@@ -357,6 +362,8 @@ npm install
@@ -357,6 +362,8 @@ npm install
cp .env.example .env # OV_LIVE_INTERNAL_URL, Network URLs, OV_OAUTH_CLIENT_SECRET, SOUNDS_PATH
cp .env.example .env # OV_LIVE_INTERNAL_URL, Network URLs, OV_OAUTH_CLIENT_SECRET, SOUNDS_PATH
npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,sounds}, /health, /ready (CHAT_CALLS=1: /ws/call, /api/streams/…)
npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,sounds}, /health, /ready (CHAT_CALLS=1: /ws/call, /api/streams/…)
npm test # Node 22; stub Live and Network in-process
npm test # Node 22; stub Live and Network in-process
+npm run test:pg # the same on PostgreSQL + PgBouncer + Valkey (OV_TEST_PG_URL, OV_TEST_PG_DIRECT_URL,
+ # OV_TEST_VALKEY_URL: openvibe-sdk scripts/test-services.sh up)
node scripts/import-from-live.js --live-db /tmp/live-snapshot.db # dry run; --apply writes
node scripts/import-from-live.js --live-db /tmp/live-snapshot.db # dry run; --apply writes
node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"
node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"
node scripts/mirror-flush.js # rollback helper: push queued mirror rows to Live
node scripts/mirror-flush.js # rollback helper: push queued mirror rows to Live
@@ -433,7 +440,7 @@ server/prefs/ chat preferences in the Network user module chat.pref
@@ -433,7 +440,7 @@ server/prefs/ chat preferences in the Network user module chat.pref
server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle
server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
-server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
+server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)
scripts/ import-from-live, parity-check, parity, mirror-flush, migrate-chat-preferences, subscribe-events, n-1-record
scripts/ import-from-live, parity-check, parity, mirror-flush, migrate-chat-preferences, subscribe-events, n-1-record
docs/ cutover.md, calls-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
docs/ cutover.md, calls-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
```
```
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W6",
"wave": "W6",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express, ws, better-sqlite3 (moving to PostgreSQL + Valkey, plan T3: migrations/0001_initial.sql is the whole schema on PostgreSQL, scripts/import-sqlite-to-pg.js moves the data, openvibe-sdk/db + /valkey; needs DATABASE_URL/DATABASE_DIRECT_URL/VALKEY_URL)",
+ "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)",
"port": 4400,
"port": 4400,
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"contracts": "openvibe-contracts v0.71.0",
"contracts": "openvibe-contracts v0.71.0",
@@ -179,7 +179,7 @@ Live becomes the chat authority again on its own tables, which the mirror kept c
@@ -179,7 +179,7 @@ Live becomes the chat authority again on its own tables, which the mirror kept c
to Live, which answers 503 until step 3 — clients retry).
to Live, which answers 503 until step 3 — clients retry).
2. Make sure everything Chat wrote is in Live: `curl -s 127.0.0.1:4400/ready | jq .mirror.pending`
2. Make sure everything Chat wrote is in Live: `curl -s 127.0.0.1:4400/ready | jq .mirror.pending`
→ 0. If the service cannot run: `cd /opt/openvibe.chat && sudo -u ubuntu env $(sudo cat
→ 0. If the service cannot run: `cd /opt/openvibe.chat && sudo -u ubuntu env $(sudo cat
- /etc/openvibe/chat.env | xargs) CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db node scripts/mirror-flush.js`
+ /etc/openvibe/chat.env | xargs) node scripts/mirror-flush.js` (the env names Chat's database: `DATABASE_URL`)
(exit 0 = drained). Live accepts mirror writes only while it still has the flag, so do this
(exit 0 = drained). Live accepts mirror writes only while it still has the flag, so do this
before step 3.
before step 3.
3. Remove `CHAT_AUTHORITY` from `/etc/openvibe/live.env`, restart Live. At boot Live applies any
3. Remove `CHAT_AUTHORITY` from `/etc/openvibe/live.env`, restart Live. At boot Live applies any
@@ -248,23 +248,39 @@ creates, so `npm run test:pg` rehearses the same shape.
@@ -248,23 +248,39 @@ creates, so `npm run test:pg` rehearses the same shape.
sqlite3 /var/lib/openvibe-chat/chat.db ".backup /tmp/chat-pg-source.db"
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
node scripts/import-sqlite-to-pg.js --sqlite /tmp/chat-pg-source.db --pglite ./data/pglite
-The import prints one line per table (rows + checksum) and records the whole report in `import_runs`. It
-runs with `ov.mirror_skip = '1'`, so the imported rows (which came from Live) are never queued back into
-Live's read mirror. A problem (a source column with no target, a verification mismatch) makes it exit 1.
-
-**Switch.** Drain Chat (stop the unit), take a final copy, and import it as the owner:
-
- node scripts/import-sqlite-to-pg.js --sqlite /var/lib/openvibe-chat/chat.db --url "$DATABASE_DIRECT_URL"
-
-Set `DATABASE_URL` and `DATABASE_DIRECT_URL` in `/etc/openvibe/chat.env`, start the new release, and check
-`GET /ready`: `db` ok (its `max_message_id` is the pre-cutover `SELECT MAX(id) FROM chat_messages`), the
-optional `valkey` check ok when `VALKEY_URL` is set (skipped otherwise), and `mirror.pending` 0 once the
-relay has flushed. Compare a few counts against the source with `scripts/parity-check.js` (repointed at
-PostgreSQL) before retiring the SQLite file.
-
-**Rollback** is the previous release plus the retained SQLite file: stop Chat, restore `DATABASE_URL` to
-unset (the old release reads `CHAT_DB_PATH`), restore the copy taken before the switch, and start the old
-release. The Live read mirror keeps Live's copy current throughout, so a rollback loses nothing Live has
-already seen. Keep the SQLite file read-only for the rollback window; do not delete it until the
-PostgreSQL deploy has served a full release.
+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 Live's read mirror, and ends with
+`ANALYZE` as the owner (the runtime role's ANALYZE is a no-op; without statistics the history page plans a
+Sort). A problem (a source column with no target, a verification mismatch) makes it exit 1.
+
+**Switch.**
+1. On the old release: `curl -s 127.0.0.1:4400/ready | jq .mirror.pending` → 0 (Live has every change), then
+ stop the unit (drained: nothing writes the file any more).
+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`), `mirror.pending`
+ back to 0 once the relay has flushed the first writes.
+5. From the deployed commit, `npm run n-1:record` and commit `test/fixtures/n-1`: the N-1 test then replays
+ this release's PostgreSQL statements again (it reports itself skipped until then).
+
+**Rollback** is the previous release plus the retained SQLite file: stop Chat, unset `DATABASE_URL` /
+`DATABASE_DIRECT_URL` (the old release reads `CHAT_DB_PATH`, which the unit still sets), and start the old
+release on `chat.db.pre-pg`. The twelve chat tables Live mirrors lose nothing Live has already seen; rows
+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.
@@ -33,7 +33,7 @@ CREATE FUNCTION instr(t text, sub text) RETURNS integer LANGUAGE sql IMMUTABLE A
@@ -33,7 +33,7 @@ CREATE FUNCTION instr(t text, sub text) RETURNS integer LANGUAGE sql IMMUTABLE A
-- ── 1. Chat-owned ──────────────────────────────────────────────
-- ── 1. Chat-owned ──────────────────────────────────────────────
CREATE TABLE chat_messages (
CREATE TABLE chat_messages (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
stream_id bigint, -- Live stream (session) id
stream_id bigint, -- Live stream (session) id
user_id bigint, -- Live user id; NULL for anon
user_id bigint, -- Live user id; NULL for anon
anon_id text COLLATE "C", -- 'anon12345' format
anon_id text COLLATE "C", -- 'anon12345' format
@@ -46,7 +46,7 @@ CREATE TABLE chat_messages (
@@ -46,7 +46,7 @@ CREATE TABLE chat_messages (
is_filtered bigint DEFAULT 0,
is_filtered bigint DEFAULT 0,
reply_to_id bigint REFERENCES chat_messages(id) ON DELETE SET NULL,
reply_to_id bigint REFERENCES chat_messages(id) ON DELETE SET NULL,
source_platform text COLLATE "C",
source_platform text COLLATE "C",
- deleted_by bigint,
+ deleted_by text COLLATE "C",
deleted_at text COLLATE "C",
deleted_at text COLLATE "C",
auto_delete_at text COLLATE "C",
auto_delete_at text COLLATE "C",
timestamp text COLLATE "C" DEFAULT ov_now(),
timestamp text COLLATE "C" DEFAULT ov_now(),
@@ -68,7 +68,7 @@ CREATE INDEX idx_chat_page_live ON chat_messages (id DESC) WHERE is_deleted = 0;
@@ -68,7 +68,7 @@ CREATE INDEX idx_chat_page_live ON chat_messages (id DESC) WHERE is_deleted = 0;
CREATE INDEX idx_chat_anon ON chat_messages(anon_id);
CREATE INDEX idx_chat_anon ON chat_messages(anon_id);
CREATE TABLE dm_conversations (
CREATE TABLE dm_conversations (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text COLLATE "C",
name text COLLATE "C",
is_group bigint DEFAULT 0,
is_group bigint DEFAULT 0,
created_by bigint NOT NULL,
created_by bigint NOT NULL,
@@ -77,7 +77,7 @@ CREATE TABLE dm_conversations (
@@ -77,7 +77,7 @@ CREATE TABLE dm_conversations (
);
);
CREATE TABLE dm_participants (
CREATE TABLE dm_participants (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
conversation_id bigint NOT NULL,
conversation_id bigint NOT NULL,
user_id bigint NOT NULL,
user_id bigint NOT NULL,
last_read_at text COLLATE "C" DEFAULT '1970-01-01 00:00:00',
last_read_at text COLLATE "C" DEFAULT '1970-01-01 00:00:00',
@@ -90,7 +90,7 @@ CREATE INDEX idx_dm_participants_conv ON dm_participants(conversation_id);
@@ -90,7 +90,7 @@ CREATE INDEX idx_dm_participants_conv ON dm_participants(conversation_id);
CREATE INDEX idx_dm_participants_user ON dm_participants(user_id);
CREATE INDEX idx_dm_participants_user ON dm_participants(user_id);
CREATE TABLE dm_messages (
CREATE TABLE dm_messages (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
conversation_id bigint NOT NULL,
conversation_id bigint NOT NULL,
sender_id bigint NOT NULL,
sender_id bigint NOT NULL,
message text COLLATE "C" NOT NULL,
message text COLLATE "C" NOT NULL,
@@ -102,7 +102,7 @@ CREATE INDEX idx_dm_messages_conv ON dm_messages(conversation_id, created_at);
@@ -102,7 +102,7 @@ CREATE INDEX idx_dm_messages_conv ON dm_messages(conversation_id, created_at);
CREATE INDEX idx_dm_messages_sender ON dm_messages(sender_id);
CREATE INDEX idx_dm_messages_sender ON dm_messages(sender_id);
CREATE TABLE dm_blocks (
CREATE TABLE dm_blocks (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
blocker_id bigint NOT NULL,
blocker_id bigint NOT NULL,
blocked_id bigint NOT NULL,
blocked_id bigint NOT NULL,
created_at text COLLATE "C" DEFAULT ov_now(),
created_at text COLLATE "C" DEFAULT ov_now(),
@@ -123,7 +123,7 @@ CREATE TABLE tts_voice_overrides (
@@ -123,7 +123,7 @@ CREATE TABLE tts_voice_overrides (
);
);
CREATE TABLE channel_sounds (
CREATE TABLE channel_sounds (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
channel_owner_id bigint NOT NULL, -- streamer whose channel this sound belongs to
channel_owner_id bigint NOT NULL, -- streamer whose channel this sound belongs to
command text COLLATE "C" NOT NULL, -- trigger word (without leading '!'), lowercased
command text COLLATE "C" NOT NULL, -- trigger word (without leading '!'), lowercased
url text COLLATE "C" NOT NULL, -- on-disk path of the audio file
url text COLLATE "C" NOT NULL, -- on-disk path of the audio file
@@ -142,7 +142,7 @@ CREATE INDEX idx_channel_sounds_owner ON channel_sounds(channel_owner_id);
@@ -142,7 +142,7 @@ CREATE INDEX idx_channel_sounds_owner ON channel_sounds(channel_owner_id);
CREATE INDEX idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);
CREATE INDEX idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);
CREATE TABLE relay_users (
CREATE TABLE relay_users (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
platform text COLLATE "C" NOT NULL,
platform text COLLATE "C" NOT NULL,
username text COLLATE "C" NOT NULL,
username text COLLATE "C" NOT NULL,
display_name text COLLATE "C",
display_name text COLLATE "C",
@@ -153,7 +153,7 @@ CREATE TABLE relay_users (
@@ -153,7 +153,7 @@ CREATE TABLE relay_users (
);
);
CREATE TABLE hidden_relay_users (
CREATE TABLE hidden_relay_users (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
channel_id bigint,
channel_id bigint,
platform text COLLATE "C" NOT NULL,
platform text COLLATE "C" NOT NULL,
external_username text COLLATE "C" NOT NULL,
external_username text COLLATE "C" NOT NULL,
@@ -165,7 +165,7 @@ CREATE TABLE hidden_relay_users (
@@ -165,7 +165,7 @@ CREATE TABLE hidden_relay_users (
CREATE INDEX idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform);
CREATE INDEX idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform);
CREATE TABLE pending_ip_messages (
CREATE TABLE pending_ip_messages (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
channel_id bigint NOT NULL,
channel_id bigint NOT NULL,
stream_id bigint,
stream_id bigint,
ip_address text COLLATE "C" NOT NULL,
ip_address text COLLATE "C" NOT NULL,
@@ -188,9 +188,9 @@ CREATE TABLE stream_first_chats (
@@ -188,9 +188,9 @@ CREATE TABLE stream_first_chats (
CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id);
CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id);
CREATE TABLE moderation_actions (
CREATE TABLE moderation_actions (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope_type text COLLATE "C" NOT NULL DEFAULT 'site',
scope_type text COLLATE "C" NOT NULL DEFAULT 'site',
- scope_id bigint,
+ scope_id text COLLATE "C",
actor_user_id bigint,
actor_user_id bigint,
target_user_id bigint,
target_user_id bigint,
action_type text COLLATE "C" NOT NULL,
action_type text COLLATE "C" NOT NULL,
@@ -205,7 +205,7 @@ CREATE INDEX idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);
@@ -205,7 +205,7 @@ CREATE INDEX idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);
-- ── 2. The six chat-target tables (Chat is their only writer since C-04) ──
-- ── 2. The six chat-target tables (Chat is their only writer since C-04) ──
CREATE TABLE channel_moderators (
CREATE TABLE channel_moderators (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
channel_id bigint NOT NULL,
channel_id bigint NOT NULL,
user_id bigint NOT NULL,
user_id bigint NOT NULL,
added_by bigint NOT NULL,
added_by bigint NOT NULL,
@@ -263,7 +263,7 @@ CREATE TABLE channel_moderation_settings (
@@ -263,7 +263,7 @@ CREATE TABLE channel_moderation_settings (
);
);
CREATE TABLE emotes (
CREATE TABLE emotes (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
user_id bigint NOT NULL,
code text COLLATE "C" NOT NULL,
code text COLLATE "C" NOT NULL,
url text COLLATE "C" NOT NULL,
url text COLLATE "C" NOT NULL,
@@ -283,7 +283,7 @@ CREATE INDEX idx_emotes_channel_owner ON emotes(channel_owner_id);
@@ -283,7 +283,7 @@ CREATE INDEX idx_emotes_channel_owner ON emotes(channel_owner_id);
CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code);
CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code);
CREATE TABLE user_tags (
CREATE TABLE user_tags (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
user_id bigint NOT NULL,
tag_id text COLLATE "C" NOT NULL,
tag_id text COLLATE "C" NOT NULL,
source text COLLATE "C" DEFAULT 'shop',
source text COLLATE "C" DEFAULT 'shop',
@@ -293,7 +293,7 @@ CREATE TABLE user_tags (
@@ -293,7 +293,7 @@ CREATE TABLE user_tags (
CREATE INDEX idx_user_tags_user ON user_tags(user_id);
CREATE INDEX idx_user_tags_user ON user_tags(user_id);
CREATE TABLE chat_ai_summaries (
CREATE TABLE chat_ai_summaries (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope text COLLATE "C" NOT NULL,
scope text COLLATE "C" NOT NULL,
subject_id bigint NOT NULL DEFAULT 0,
subject_id bigint NOT NULL DEFAULT 0,
"window" text COLLATE "C" NOT NULL,
"window" text COLLATE "C" NOT NULL,
@@ -311,7 +311,7 @@ CREATE TABLE chat_ai_summaries (
@@ -311,7 +311,7 @@ CREATE TABLE chat_ai_summaries (
);
);
CREATE TABLE chat_timeline_events (
CREATE TABLE chat_timeline_events (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
scope text COLLATE "C" NOT NULL DEFAULT 'global',
scope text COLLATE "C" NOT NULL DEFAULT 'global',
subject_id bigint NOT NULL DEFAULT 0,
subject_id bigint NOT NULL DEFAULT 0,
ts text COLLATE "C" NOT NULL,
ts text COLLATE "C" NOT NULL,
@@ -385,7 +385,7 @@ CREATE TABLE ctx_sync (
@@ -385,7 +385,7 @@ CREATE TABLE ctx_sync (
-- Transactional outbox (events.event-envelope@1), written in the same transaction as the change.
-- Transactional outbox (events.event-envelope@1), written in the same transaction as the change.
CREATE TABLE events_outbox (
CREATE TABLE events_outbox (
- seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ seq bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
event_id text COLLATE "C" NOT NULL UNIQUE,
event_id text COLLATE "C" NOT NULL UNIQUE,
event_type text COLLATE "C" NOT NULL,
event_type text COLLATE "C" NOT NULL,
event text COLLATE "C" NOT NULL,
event text COLLATE "C" NOT NULL,
@@ -399,7 +399,7 @@ CREATE INDEX idx_events_outbox_unsent ON events_outbox(sent_at, seq);
@@ -399,7 +399,7 @@ CREATE INDEX idx_events_outbox_unsent ON events_outbox(sent_at, seq);
-- Row changes to mirror into Live's copy of Chat's tables (filled by per-connection TEMP
-- Row changes to mirror into Live's copy of Chat's tables (filled by per-connection TEMP
-- triggers, so only writes made by this service are captured — never the importer's).
-- triggers, so only writes made by this service are captured — never the importer's).
CREATE TABLE live_mirror_outbox (
CREATE TABLE live_mirror_outbox (
- seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ seq bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tbl text COLLATE "C" NOT NULL,
tbl text COLLATE "C" NOT NULL,
op text COLLATE "C" NOT NULL CHECK(op IN ('upsert', 'delete')),
op text COLLATE "C" NOT NULL CHECK(op IN ('upsert', 'delete')),
pk text COLLATE "C" NOT NULL,
pk text COLLATE "C" NOT NULL,
@@ -419,7 +419,7 @@ CREATE TABLE bridge_applied (
@@ -419,7 +419,7 @@ CREATE TABLE bridge_applied (
-- queued → playing → played, or skipped (moderators, the broadcaster) / failed. payload holds what
-- queued → playing → played, or skipped (moderators, the broadcaster) / failed. payload holds what
-- is needed to make the audio when its turn comes, so the queue survives a restart. Times are ms.
-- is needed to make the audio when its turn comes, so the queue survives a restart. Times are ms.
CREATE TABLE audio_requests (
CREATE TABLE audio_requests (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
room text COLLATE "C" NOT NULL,
room text COLLATE "C" NOT NULL,
stream_id bigint,
stream_id bigint,
channel_user_id bigint,
channel_user_id bigint,
@@ -455,7 +455,7 @@ CREATE TABLE bridge_refs (
@@ -455,7 +455,7 @@ CREATE TABLE bridge_refs (
-- Rows the importer could not represent. Never dropped; reviewed by hand.
-- Rows the importer could not represent. Never dropped; reviewed by hand.
CREATE TABLE import_hold (
CREATE TABLE import_hold (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
source_table text COLLATE "C" NOT NULL,
source_table text COLLATE "C" NOT NULL,
source_pk text COLLATE "C" NOT NULL,
source_pk text COLLATE "C" NOT NULL,
reason text COLLATE "C" NOT NULL,
reason text COLLATE "C" NOT NULL,
@@ -465,7 +465,7 @@ CREATE TABLE import_hold (
@@ -465,7 +465,7 @@ CREATE TABLE import_hold (
);
);
CREATE TABLE import_runs (
CREATE TABLE import_runs (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
live_db text COLLATE "C",
live_db text COLLATE "C",
dry_run bigint DEFAULT 0,
dry_run bigint DEFAULT 0,
counts text COLLATE "C",
counts text COLLATE "C",
@@ -511,7 +511,7 @@ CREATE TABLE chat_event_inbox (
@@ -511,7 +511,7 @@ CREATE TABLE chat_event_inbox (
-- a session of a voice channel (a stream-linked one for 'stream'): active from the first person in
-- a session of a voice channel (a stream-linked one for 'stream'): active from the first person in
-- until the channel empties or is closed → ended. Times are ms.
-- until the channel empties or is closed → ended. Times are ms.
CREATE TABLE calls (
CREATE TABLE calls (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
kind text COLLATE "C" NOT NULL CHECK(kind IN ('channel', 'direct', 'stream')),
kind text COLLATE "C" NOT NULL CHECK(kind IN ('channel', 'direct', 'stream')),
state text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'ringing', 'active', 'ended', 'missed', 'declined', 'failed')),
state text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'ringing', 'active', 'ended', 'missed', 'declined', 'failed')),
channel_id text COLLATE "C" NOT NULL,
channel_id text COLLATE "C" NOT NULL,
@@ -531,7 +531,7 @@ CREATE INDEX idx_calls_open ON calls(state) WHERE state IN ('pending', 'ringing'
@@ -531,7 +531,7 @@ CREATE INDEX idx_calls_open ON calls(state) WHERE state IN ('pending', 'ringing'
CREATE TABLE rooms (
CREATE TABLE rooms (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
slug text COLLATE "C" UNIQUE NOT NULL,
slug text COLLATE "C" UNIQUE NOT NULL,
name text COLLATE "C" NOT NULL,
name text COLLATE "C" NOT NULL,
topic text COLLATE "C",
topic text COLLATE "C",
@@ -557,7 +557,7 @@ CREATE TABLE room_members (
@@ -557,7 +557,7 @@ CREATE TABLE room_members (
);
);
CREATE INDEX idx_room_members_user ON room_members(user_id);
CREATE INDEX idx_room_members_user ON room_members(user_id);
CREATE TABLE room_messages (
CREATE TABLE room_messages (
- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
room_id bigint NOT NULL,
room_id bigint NOT NULL,
user_id bigint NOT NULL,
user_id bigint NOT NULL,
subject_id text COLLATE "C",
subject_id text COLLATE "C",
@@ -9,7 +9,6 @@
@@ -9,7 +9,6 @@
"version": "0.1.0",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
- "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
@@ -24,7 +23,8 @@
@@ -24,7 +23,8 @@
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "@electric-sql/pglite-socket": "0.2.11"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -37,6 +37,103 @@
@@ -37,6 +37,103 @@
"devOptional": true,
"devOptional": true,
"license": "Apache-2.0"
"license": "Apache-2.0"
},
},
+ "node_modules/@electric-sql/pglite-age": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-age/-/pglite-age-0.0.9.tgz",
+ "integrity": "sha512-IdNy5P5nxwKVeSPLjDLXkp/eYffH5roympM7fTG+g9ehzBrDnQ9TT2tfCJ2vRVcOSPQBp4oURzfRE19cs/x8ZA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.8"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pg_hashids": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_hashids/-/pglite-pg_hashids-0.0.9.tgz",
+ "integrity": "sha512-NffsZH+FaRLp0WzsYG35BW/wOHLsTs325zjgp+PnKlWeGyW523Mbw4EZ1Awm02C3QWBU9p8Y2Fk02Al5NpRGUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.8"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pg_ivm": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_ivm/-/pglite-pg_ivm-0.0.9.tgz",
+ "integrity": "sha512-mbTcDLQyzeOG4rX6LLHhWDPJeQUv2xHvayKtf4oE7xujWEGWEDUEJP1+U0pXXELtIoWiUzzPBWjzKY1mRisqkQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.8"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pg_textsearch": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_textsearch/-/pglite-pg_textsearch-0.0.10.tgz",
+ "integrity": "sha512-tsL1rTYU4tzIC44htL7/0nS6C2XC5Srz0tfxU1LJelCqNIBkpFI9Esw1JbuVCaQKUHGDbPgAz8JYavNxwbuRVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.8"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pg_uuidv7": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pg_uuidv7/-/pglite-pg_uuidv7-0.0.9.tgz",
+ "integrity": "sha512-n/JbzoQMF9jFKFxGLRIzUfC1O4V2vTE0Pt3uoAO6/EP7dX5m1h4zBdezQAcT2JFostD0YWUdzHj9iYvcbUGM1A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.8"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pgtap": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pgtap/-/pglite-pgtap-0.0.9.tgz",
+ "integrity": "sha512-VIyclfOawUN0IJROKiCnrVxsZQEG7tixgiC8c8nxp9TlBhI79MZqotoqlqvdPzGHmjWZlcgJ7tC8InOcy+2A/A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.8"
+ }
+ },
+ "node_modules/@electric-sql/pglite-pgvector": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-pgvector/-/pglite-pgvector-0.0.9.tgz",
+ "integrity": "sha512-ue4iBW651gDQwBwn97Ekv1lYGPvXa1ymHbRbTCSL0Ib286PRDD1VDOwzwEoekZuO/wctMbTzKzlwdgDwYrqZ8A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.8"
+ }
+ },
+ "node_modules/@electric-sql/pglite-socket": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.2.11.tgz",
+ "integrity": "sha512-DKnJBf7+5zFCXXGZAGUvGoWKetPPvaQ7fLSGVqP5onzgBhV9vvTcHigireMROMglgovvhoXHaMOKqt69/vts4A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "pglite-server": "dist/scripts/server.js"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.5.8",
+ "@electric-sql/pglite-age": "0.0.9",
+ "@electric-sql/pglite-pg_hashids": "0.0.9",
+ "@electric-sql/pglite-pg_ivm": "0.0.9",
+ "@electric-sql/pglite-pg_textsearch": "0.0.10",
+ "@electric-sql/pglite-pg_uuidv7": "0.0.9",
+ "@electric-sql/pglite-pgtap": "0.0.9",
+ "@electric-sql/pglite-pgvector": "0.0.9"
+ }
+ },
"node_modules/@iovalkey/commands": {
"node_modules/@iovalkey/commands": {
"version": "0.1.0",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
@@ -119,7 +216,9 @@
@@ -119,7 +216,9 @@
"url": "https://feross.org/support"
"url": "https://feross.org/support"
}
}
],
],
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/better-sqlite3": {
"node_modules/better-sqlite3": {
"version": "11.10.0",
"version": "11.10.0",
@@ -127,6 +226,8 @@
@@ -127,6 +226,8 @@
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true,
"hasInstallScript": true,
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"bindings": "^1.5.0",
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
"prebuild-install": "^7.1.1"
@@ -137,6 +238,8 @@
@@ -137,6 +238,8 @@
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"file-uri-to-path": "1.0.0"
"file-uri-to-path": "1.0.0"
}
}
@@ -146,6 +249,8 @@
@@ -146,6 +249,8 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"buffer": "^5.5.0",
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"inherits": "^2.0.4",
@@ -195,6 +300,8 @@
@@ -195,6 +300,8 @@
}
}
],
],
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"base64-js": "^1.3.1",
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
"ieee754": "^1.1.13"
@@ -259,7 +366,9 @@
@@ -259,7 +366,9 @@
"version": "1.1.4",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "license": "ISC"
+ "license": "ISC",
+ "optional": true,
+ "peer": true
},
},
"node_modules/cluster-key-slot": {
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"version": "1.1.2",
@@ -350,6 +459,8 @@
@@ -350,6 +459,8 @@
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"mimic-response": "^3.1.0"
"mimic-response": "^3.1.0"
},
},
@@ -365,6 +476,8 @@
@@ -365,6 +476,8 @@
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
@@ -402,6 +515,8 @@
@@ -402,6 +515,8 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=8"
"node": ">=8"
}
}
@@ -461,6 +576,8 @@
@@ -461,6 +576,8 @@
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"once": "^1.4.0"
"once": "^1.4.0"
}
}
@@ -515,6 +632,8 @@
@@ -515,6 +632,8 @@
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"license": "(MIT OR WTFPL)",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=6"
"node": ">=6"
}
}
@@ -606,7 +725,9 @@
@@ -606,7 +725,9 @@
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/finalhandler": {
"node_modules/finalhandler": {
"version": "1.3.2",
"version": "1.3.2",
@@ -648,7 +769,9 @@
@@ -648,7 +769,9 @@
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/function-bind": {
"node_modules/function-bind": {
"version": "1.1.2",
"version": "1.1.2",
@@ -700,7 +823,9 @@
@@ -700,7 +823,9 @@
"version": "0.0.0",
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/gopd": {
"node_modules/gopd": {
"version": "1.2.0",
"version": "1.2.0",
@@ -788,7 +913,9 @@
@@ -788,7 +913,9 @@
"url": "https://feross.org/support"
"url": "https://feross.org/support"
}
}
],
],
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "peer": true
},
},
"node_modules/inherits": {
"node_modules/inherits": {
"version": "2.0.4",
"version": "2.0.4",
@@ -800,7 +927,9 @@
@@ -800,7 +927,9 @@
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
- "license": "ISC"
+ "license": "ISC",
+ "optional": true,
+ "peer": true
},
},
"node_modules/iovalkey": {
"node_modules/iovalkey": {
"version": "0.4.0",
"version": "0.4.0",
@@ -1037,6 +1166,8 @@
@@ -1037,6 +1166,8 @@
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=10"
"node": ">=10"
},
},
@@ -1049,6 +1180,8 @@
@@ -1049,6 +1180,8 @@
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"funding": {
"funding": {
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
@@ -1057,7 +1190,9 @@
@@ -1057,7 +1190,9 @@
"version": "0.5.3",
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/ms": {
"node_modules/ms": {
"version": "2.0.0",
"version": "2.0.0",
@@ -1087,7 +1222,9 @@
@@ -1087,7 +1222,9 @@
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/negotiator": {
"node_modules/negotiator": {
"version": "0.6.3",
"version": "0.6.3",
@@ -1103,6 +1240,8 @@
@@ -1103,6 +1240,8 @@
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
"integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
"integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"semver": "^7.3.5"
"semver": "^7.3.5"
},
},
@@ -1148,6 +1287,8 @@
@@ -1148,6 +1287,8 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"license": "ISC",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"wrappy": "1"
"wrappy": "1"
}
}
@@ -1354,6 +1495,8 @@
@@ -1354,6 +1495,8 @@
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"detect-libc": "^2.0.0",
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"expand-template": "^2.0.3",
@@ -1397,6 +1540,8 @@
@@ -1397,6 +1540,8 @@
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"end-of-stream": "^1.1.0",
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
"once": "^1.3.1"
@@ -1447,6 +1592,8 @@
@@ -1447,6 +1592,8 @@
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"deep-extend": "^0.6.0",
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"ini": "~1.3.0",
@@ -1462,6 +1609,8 @@
@@ -1462,6 +1609,8 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"inherits": "^2.0.3",
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"string_decoder": "^1.1.1",
@@ -1680,7 +1829,9 @@
@@ -1680,7 +1829,9 @@
"url": "https://feross.org/support"
"url": "https://feross.org/support"
}
}
],
],
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/simple-get": {
"node_modules/simple-get": {
"version": "4.0.1",
"version": "4.0.1",
@@ -1701,6 +1852,8 @@
@@ -1701,6 +1852,8 @@
}
}
],
],
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"decompress-response": "^6.0.0",
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"once": "^1.3.1",
@@ -1744,6 +1897,8 @@
@@ -1744,6 +1897,8 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"safe-buffer": "~5.2.0"
"safe-buffer": "~5.2.0"
}
}
@@ -1753,6 +1908,8 @@
@@ -1753,6 +1908,8 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"engines": {
"engines": {
"node": ">=0.10.0"
"node": ">=0.10.0"
}
}
@@ -1762,6 +1919,8 @@
@@ -1762,6 +1919,8 @@
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"chownr": "^1.1.1",
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"mkdirp-classic": "^0.5.2",
@@ -1774,6 +1933,8 @@
@@ -1774,6 +1933,8 @@
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"bl": "^4.0.3",
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"end-of-stream": "^1.4.1",
@@ -1799,6 +1960,8 @@
@@ -1799,6 +1960,8 @@
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
"dependencies": {
"dependencies": {
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
},
},
@@ -1832,7 +1995,9 @@
@@ -1832,7 +1995,9 @@
"version": "1.0.2",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "peer": true
},
},
"node_modules/utils-merge": {
"node_modules/utils-merge": {
"version": "1.0.1",
"version": "1.0.1",
@@ -1856,7 +2021,9 @@
@@ -1856,7 +2021,9 @@
"version": "1.0.2",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "license": "ISC"
+ "license": "ISC",
+ "optional": true,
+ "peer": true
},
},
"node_modules/ws": {
"node_modules/ws": {
"version": "8.21.3",
"version": "8.21.3",
@@ -22,7 +22,6 @@
@@ -22,7 +22,6 @@
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
- "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
@@ -37,7 +36,8 @@
@@ -37,7 +36,8 @@
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "@electric-sql/pglite-socket": "0.2.11"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -3,19 +3,24 @@
@@ -3,19 +3,24 @@
* Copy OpenVibe.Live's chat tables into OpenVibe.Chat's database.
* 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]]
* node scripts/import-from-live.js --live-db <copy of live.db> [--apply [--backup <path> | --no-backup]]
- * [--chat-db <path>] [--headroom <n>] [--tables a,b] [--no-projections]
+ * [--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.
* 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
* - A dry run unless --apply: the report says what a run would do and nothing is written. --apply
- * first copies Chat's database (VACUUM INTO <chat db>.pre-import-<time>, or --backup <path>) and
- * says where in the report.
+ * 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;
* - 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.
* 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
* - 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, so rows Live writes between the
+ * `--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.
* 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:
* - 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
* an id already used by a DIFFERENT row, a constraint the row breaks, rows of Live's
* transient *_new migration tables.
* transient *_new migration tables.
@@ -33,15 +38,15 @@
@@ -33,15 +38,15 @@
*/
*/
'use strict';
'use strict';
+const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { openSqlite } = require('./lib/sqlite');
function parseArgs(argv) {
function parseArgs(argv) {
const out = { dryRun: true, backup: true, headroom: 10000, projections: true, tables: null };
const out = { dryRun: true, backup: true, headroom: 10000, projections: true, tables: null };
for (let i = 0; i < argv.length; i++) {
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const a = argv[i];
if (a === '--live-db') out.liveDb = argv[++i];
if (a === '--live-db') out.liveDb = argv[++i];
- else if (a === '--chat-db') out.chatDb = argv[++i];
else if (a === '--dry-run') out.dryRun = true;
else if (a === '--dry-run') out.dryRun = true;
else if (a === '--apply') out.dryRun = false;
else if (a === '--apply') out.dryRun = false;
else if (a === '--backup') out.backupPath = argv[++i];
else if (a === '--backup') out.backupPath = argv[++i];
@@ -82,95 +87,107 @@ const SUBJECT_COLUMNS = {
@@ -82,95 +87,107 @@ const SUBJECT_COLUMNS = {
};
};
const TRANSIENT = ['chat_messages_new', 'emotes_new', 'channel_sounds_new'];
const TRANSIENT = ['chat_messages_new', 'emotes_new', 'channel_sounds_new'];
const NOT_MOVED = ['media_requests', 'media_request_settings'];
const NOT_MOVED = ['media_requests', 'media_request_settings'];
+const PROJECTIONS = ['ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels'];
class Rollback extends Error {}
class Rollback extends Error {}
-/** Copy Chat's database before a write run (VACUUM INTO: consistent, compact, the WAL included). */
-function backupChat(chat, target) {
- const fs = require('fs');
- const file = path.resolve(target || `${chat.name}.pre-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
+// 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`);
if (fs.existsSync(file)) throw new Error(`backup ${file} already exists`);
- chat.prepare('VACUUM INTO ?').run(file);
+ 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;
return file;
}
}
-function run(opts) {
+async function run(opts) {
if (!opts.liveDb) throw new Error('--live-db <snapshot> is required');
if (!opts.liveDb) throw new Error('--live-db <snapshot> is required');
- if (opts.chatDb) process.env.CHAT_DB_PATH = opts.chatDb;
const db = require('../server/db/database');
const db = require('../server/db/database');
- // No mirror capture: these rows came from Live.
- db.initDb({ captureMirror: false });
+ await db.initDb();
const chat = db.getDb();
const chat = db.getDb();
- const live = new Database(path.resolve(opts.liveDb), { readonly: true, fileMustExist: true });
+ 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 liveTables = new Set(live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
- const colsOf = (conn, t) => conn.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
+ 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.
// The six tables Chat owns (C-04 done) are not imported: Chat is their only writer.
const plan = [
const plan = [
- ...Object.entries(db.CHAT_TABLES).map(([t, pk]) => ({ table: t, pk })),
+ ...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));
].filter((p) => !opts.tables || opts.tables.includes(p.table));
- // Schema drift check first: nothing is written when Live has columns Chat cannot hold.
+ // 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 = [];
const drift = [];
for (const p of plan) {
for (const p of plan) {
if (!liveTables.has(p.table)) continue;
if (!liveTables.has(p.table)) continue;
- const chatCols = new Set(colsOf(chat, p.table));
- const extra = colsOf(live, p.table).filter((c) => !chatCols.has(c) && !(LIVE_ONLY_COLUMNS[p.table] || []).includes(c));
+ 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 (extra.length) drift.push(`${p.table}: ${extra.join(', ')}`);
}
}
if (drift.length) {
if (drift.length) {
live.close();
live.close();
- const err = new Error(`Live has columns Chat does not know — update server/db/schema.sql first:\n ${drift.join('\n ')}`);
+ 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';
err.code = 'SCHEMA_DRIFT';
throw err;
throw err;
}
}
// Network subjects by Live user id (newest link wins, as Live resolves them).
// Network subjects by Live user id (newest link wins, as Live resolves them).
const subjects = new Map();
const subjects = new Map();
- if (liveTables.has('linked_accounts') && colsOf(live, 'linked_accounts').includes('subject_id')) {
+ 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);
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 OR IGNORE INTO import_hold (source_table, source_pk, reason, row_json) VALUES (?, ?, ?, ?)');
- const hold = (table, pkObj, reason, row) => holdStmt.run(table, JSON.stringify(pkObj), String(reason).slice(0, 300), JSON.stringify(row)).changes;
+ 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: {} };
const report = { live_db: path.resolve(opts.liveDb), dry_run: !!opts.dryRun, backup: null, tables: {} };
- if (!opts.dryRun && opts.backup) report.backup = backupChat(chat, opts.backupPath);
+ 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) {
for (const p of plan) {
const r = { live: 0, inserted: 0, identical: 0, chat_kept: 0, held: 0 };
const r = { live: 0, inserted: 0, identical: 0, chat_kept: 0, held: 0 };
report.tables[p.table] = r;
report.tables[p.table] = r;
if (!liveTables.has(p.table)) { r.missing_in_live = true; continue; }
if (!liveTables.has(p.table)) { r.missing_in_live = true; continue; }
- const chatCols = new Set(colsOf(chat, p.table));
- const cols = colsOf(live, p.table).filter((c) => chatCols.has(c));
+ 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 subjectCols = (SUBJECT_COLUMNS[p.table] || []).filter(([sc]) => chatCols.has(sc));
- const insertCols = cols.concat(subjectCols.map(([sc]) => sc));
- const where = p.pk.map((k) => `${k} = ?`).join(' AND ');
- const getChat = chat.prepare(`SELECT * FROM ${p.table} WHERE ${where}`);
- const ins = chat.prepare(`INSERT INTO ${p.table} (${insertCols.join(', ')}) VALUES (${insertCols.map(() => '?').join(', ')})`);
+ const 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 identity = IDENTITY[p.table] || null;
- const orderBy = p.pk.join(', ');
+ const orderBy = p.pk.map(ident).join(', ');
- const apply = () => {
- for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${p.table} ORDER BY ${orderBy}`).iterate()) {
+ const apply = async () => {
+ for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${ident(p.table)} ORDER BY ${orderBy}`).iterate()) {
r.live++;
r.live++;
const pkVals = p.pk.map((k) => row[k]);
const pkVals = p.pk.map((k) => row[k]);
const pkObj = Object.fromEntries(p.pk.map((k) => [k, row[k]]));
const pkObj = Object.fromEntries(p.pk.map((k) => [k, row[k]]));
- const existing = getChat.get(...pkVals);
+ const existing = await getChat.get(...pkVals);
if (!existing) {
if (!existing) {
+ // A savepoint per row: a row that breaks a constraint is held, the rest of the table still goes.
try {
try {
- ins.run(...cols.map((c) => row[c]), ...subjectCols.map(([, uidCol]) => subjects.get(row[uidCol]) || null));
+ await chat.tx(() => ins.run(...cols.map((c) => row[c]), ...subjectCols.map(([, uidCol]) => subjects.get(row[uidCol]) || null)));
r.inserted++;
r.inserted++;
} catch (err) {
} catch (err) {
- r.held += hold(p.table, pkObj, `insert failed: ${err.message}`, row);
+ r.held += await hold(p.table, pkObj, `insert failed: ${err.message}`, row);
}
}
continue;
continue;
}
}
- const same = cols.every((c) => existing[c] === row[c] || (existing[c] == null && row[c] == null));
- if (same) { r.identical++; continue; }
- if (identity && !identity.every((c) => !cols.includes(c) || existing[c] === row[c] || (existing[c] == null && row[c] == null))) {
+ 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.
// Same id, different row: Chat's stays, Live's is held for a person to look at.
- r.held += hold(p.table, pkObj, 'id already used by a different row in chat', row);
+ r.held += await hold(p.table, pkObj, 'id already used by a different row in chat', row);
continue;
continue;
}
}
r.chat_kept++;
r.chat_kept++;
@@ -178,7 +195,7 @@ function run(opts) {
@@ -178,7 +195,7 @@ function run(opts) {
};
};
try {
try {
- chat.transaction(() => { apply(); if (opts.dryRun) throw new Rollback(); })();
+ await chat.tx(async () => { await noMirror(); await apply(); if (opts.dryRun) throw new Rollback(); });
} catch (err) {
} catch (err) {
if (!(err instanceof Rollback)) throw err;
if (!(err instanceof Rollback)) throw err;
}
}
@@ -191,81 +208,88 @@ function run(opts) {
@@ -191,81 +208,88 @@ function run(opts) {
const r = { live: 0, held: 0 };
const r = { live: 0, held: 0 };
report.tables[t] = r;
report.tables[t] = r;
try {
try {
- chat.transaction(() => {
- const pkCol = colsOf(live, t).includes('id') ? 'id' : 'rowid';
- for (const row of live.prepare(`SELECT rowid AS _rowid, * FROM ${t}`).iterate()) {
+ 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.live++;
- r.held += hold(t, { [pkCol]: pkCol === 'id' ? row.id : row._rowid }, 'row of a transient migration table (Live rebuilds tables through <name>_new)', row);
+ 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();
if (opts.dryRun) throw new Rollback();
- })();
+ });
} catch (err) { if (!(err instanceof Rollback)) throw err; }
} catch (err) { if (!(err instanceof Rollback)) throw err; }
}
}
for (const t of NOT_MOVED) {
for (const t of NOT_MOVED) {
- if (liveTables.has(t)) report.tables[t] = { live: live.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n, not_moved: 'stays in Live (media queue is Live-owned in W6)' };
+ 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) {
if (!opts.dryRun) {
- // Chat's new ids start above Live's highest id plus headroom (never lowered).
- chat.transaction(() => {
+ // 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)) {
for (const [t] of Object.entries(db.CHAT_TABLES)) {
// (only the tables this run imported: --tables leaves the others as they are)
// (only the tables this run imported: --tables leaves the others as they are)
- if (!report.tables[t] || !liveTables.has(t) || !colsOf(live, t).includes('id')) continue;
- const liveMax = live.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${t}`).get().m;
- const chatMax = chat.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${t}`).get().m;
- const target = Math.max(liveMax + opts.headroom, chatMax);
- const cur = chat.prepare('SELECT seq FROM sqlite_sequence WHERE name = ?').get(t);
- if (!cur) chat.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(t, target);
- else if (cur.seq < target) chat.prepare('UPDATE sqlite_sequence SET seq = ? WHERE name = ?').run(target, t);
- report.tables[t].next_id = Math.max(target, cur ? cur.seq : 0) + 1;
+ 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) {
if (opts.projections) {
- const seed = (table, sql, cols) => {
+ const seed = async (table, sql, cols) => {
if (!liveTables.has(sql.table)) return 0;
if (!liveTables.has(sql.table)) return 0;
- const have = new Set(colsOf(live, sql.table));
+ const have = new Set(colsOfLive(sql.table));
const pick = cols.filter((c) => have.has(c.from || c.to));
const pick = cols.filter((c) => have.has(c.from || c.to));
- const st = chat.prepare(`INSERT INTO ${table} (${pick.map((c) => c.to).join(', ')}, synced_at) VALUES (${pick.map(() => '?').join(', ')}, 0)
+ 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`);
ON CONFLICT(id) DO NOTHING`);
let n = 0;
let n = 0;
- chat.transaction(() => {
- for (const row of live.prepare(`SELECT ${pick.map((c) => c.from || c.to).join(', ')} FROM ${sql.table}`).iterate()) {
- n += st.run(...pick.map((c) => (c.to === 'subject_id' ? (subjects.get(row.id) || null) : row[c.from || c.to]))).changes;
+ 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;
return n;
};
};
const c = (to, from) => ({ to, from });
const c = (to, from) => ({ to, from });
report.projections = {
report.projections = {
- ctx_users: 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: 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: seed('ctx_managed_streams', { table: 'managed_streams' }, [c('id'), c('user_id'), c('slug'), c('title'), c('sort_order'), c('created_at')]),
- ctx_channels: seed('ctx_channels', { table: 'channels' }, [c('id'), c('user_id'), c('title')]),
+ 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')]),
};
};
}
}
- chat.prepare('INSERT INTO import_runs (live_db, dry_run, counts, finished_at) VALUES (?, 0, ?, CURRENT_TIMESTAMP)').run(report.live_db, JSON.stringify(report.tables));
+ 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 = chat.prepare('SELECT COUNT(*) AS n FROM import_hold').get().n;
+ report.held_total = (await chat.prepare('SELECT COUNT(*) AS n FROM import_hold').get()).n;
live.close();
live.close();
return report;
return report;
}
}
-if (require.main === module) {
+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;
let opts;
- try { opts = parseArgs(process.argv.slice(2)); } catch (err) { console.error(err.message); process.exit(2); }
- if (opts.help || !opts.liveDb) {
- console.log('usage: node scripts/import-from-live.js --live-db <snapshot> [--apply [--backup <path> | --no-backup]] [--chat-db <path>] [--headroom <n>] [--tables a,b] [--no-projections]');
- process.exit(opts.help ? 0 : 2);
- }
+ 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 {
try {
- const report = run(opts);
- console.log(JSON.stringify(report, null, 2));
- try { require('../server/db/database').close(); } catch { /* */ }
+ const report = await run(opts);
+ return { code: 0, stdout: `${JSON.stringify(report, null, 2)}\n`, stderr: '' };
} catch (err) {
} catch (err) {
- console.error(err.message);
- process.exit(err.code === 'SCHEMA_DRIFT' ? 1 : 3);
+ return { code: err.code === 'SCHEMA_DRIFT' ? 1 : 3, stdout: '', stderr: `${err.message}\n` };
}
}
}
}
-module.exports = { run, parseArgs, IDENTITY, SUBJECT_COLUMNS, LIVE_ONLY_COLUMNS };
+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 };
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
const path = require('path');
const path = require('path');
const { createDb, importSqlite } = require('openvibe-sdk/db');
const { createDb, importSqlite } = require('openvibe-sdk/db');
+const { openSqlite } = require('./lib/sqlite');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
@@ -45,12 +46,16 @@ async function main(argv) {
@@ -45,12 +46,16 @@ async function main(argv) {
? createDb({ pglite: args.pglite, service: 'chat-import', max: 1 })
? createDb({ pglite: args.pglite, service: 'chat-import', max: 1 })
: createDb({ url: args.url, service: 'chat-import', max: 1 });
: createDb({ url: args.url, service: 'chat-import', max: 1 });
try {
try {
- if (args.pglite) await db.migrate({ dir: MIGRATIONS });
+ // 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).
// 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'`);
await db.query(`SET ov.mirror_skip = '1'`);
const started = new Date().toISOString().replace('T', ' ').slice(0, 19);
const started = new Date().toISOString().replace('T', ' ').slice(0, 19);
- const report = await importSqlite({ sqlite: args.sqlite, db, truncate: args.truncate, verify: args.verify });
+ // 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
// 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
// role is DML-only, so its ANALYZE is silently skipped and it would have no column statistics until
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'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 };
@@ -70,10 +70,10 @@ async function main() {
@@ -70,10 +70,10 @@ async function main() {
console.log(JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
return;
return;
}
}
- const Database = require('better-sqlite3');
- const liveDb = new Database(args.liveDb, { readonly: true, fileMustExist: true });
+ const { openSqlite } = require('./lib/sqlite');
+ const liveDb = openSqlite(args.liveDb);
let planned;
let planned;
- try { planned = migration.plan(liveDb); } finally { liveDb.close(); }
+ 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) });
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));
console.log(JSON.stringify(report, null, 2));
if (!args.apply) console.error('dry run: nothing written (add --apply --backup <file> to write)');
if (!args.apply) console.error('dry run: nothing written (add --apply --backup <file> to write)');
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
* Options:
* Options:
* --out <file> required with --apply; must not exist
* --out <file> required with --apply; must not exist
* --resolve first ask Live (live-context, as Chat does at runtime) for people missing from ctx_users
* --resolve first ask Live (live-context, as Chat does at runtime) for people missing from ctx_users
- * --db <path> Chat's database (default CHAT_DB_PATH, else ./data/chat.db)
+ * Chat's database is the service's (DATABASE_URL / DATABASE_DIRECT_URL, or the development PGlite).
*
*
* People are Live ids here; each is mapped to its Network subject the way Chat records it: the blocker's
* People are Live ids here; each is mapped to its Network subject the way Chat records it: the blocker's
* dm_blocks.blocker_subject_id (written when they blocked), else ctx_users.subject_id; the blocked person's
* dm_blocks.blocker_subject_id (written when they blocked), else ctx_users.subject_id; the blocked person's
@@ -28,9 +28,8 @@
@@ -28,9 +28,8 @@
'use strict';
'use strict';
const fs = require('fs');
const fs = require('fs');
-const path = require('path');
-const USAGE = 'usage: node scripts/migrate-dm-blocks-to-network.js [--db <chat.db>] [--resolve] [--apply --out <file>]';
+const USAGE = 'usage: node scripts/migrate-dm-blocks-to-network.js [--resolve] [--apply --out <file>]';
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
function parseArgs(argv) {
function parseArgs(argv) {
@@ -42,7 +41,6 @@ function parseArgs(argv) {
@@ -42,7 +41,6 @@ function parseArgs(argv) {
else if (a === '--dry-run') out.apply = false;
else if (a === '--dry-run') out.apply = false;
else if (a === '--resolve') out.resolve = true;
else if (a === '--resolve') out.resolve = true;
else if (a === '--out') out.out = value();
else if (a === '--out') out.out = value();
- else if (a === '--db') out.db = value();
else if (a === '-h' || a === '--help') out.help = true;
else if (a === '-h' || a === '--help') out.help = true;
else throw new Error(`unknown argument ${a}`);
else throw new Error(`unknown argument ${a}`);
}
}
@@ -52,8 +50,8 @@ function parseArgs(argv) {
@@ -52,8 +50,8 @@ function parseArgs(argv) {
}
}
/** dm_blocks → { pairs: [{ blocker_subject, blocked_subject }], rows, skipped: { reason: n }, unmapped_ids } */
/** dm_blocks → { pairs: [{ blocker_subject, blocked_subject }], rows, skipped: { reason: n }, unmapped_ids } */
-function plan(db) {
- const rows = db.all(`SELECT b.id, b.blocker_id, b.blocked_id, b.blocker_subject_id, a.subject_id AS blocker_ctx, c.subject_id AS blocked_ctx
+async function plan(db) {
+ const rows = await db.all(`SELECT b.id, b.blocker_id, b.blocked_id, b.blocker_subject_id, a.subject_id AS blocker_ctx, c.subject_id AS blocked_ctx
FROM dm_blocks b LEFT JOIN ctx_users a ON a.id = b.blocker_id LEFT JOIN ctx_users c ON c.id = b.blocked_id ORDER BY b.id`);
FROM dm_blocks b LEFT JOIN ctx_users a ON a.id = b.blocker_id LEFT JOIN ctx_users c ON c.id = b.blocked_id ORDER BY b.id`);
const ok = (s) => (SUBJECT_RE.test(String(s || '')) ? String(s) : null);
const ok = (s) => (SUBJECT_RE.test(String(s || '')) ? String(s) : null);
const pairs = [];
const pairs = [];
@@ -79,21 +77,18 @@ async function main(argv, log = (m) => console.log(m)) {
@@ -79,21 +77,18 @@ async function main(argv, log = (m) => console.log(m)) {
let args;
let args;
try { args = parseArgs(argv); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
try { args = parseArgs(argv); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
if (args.help) { log(USAGE); return 0; }
if (args.help) { log(USAGE); return 0; }
- if (args.db) {
- if (!fs.existsSync(args.db)) { log(`no Chat database at ${args.db}`); return 2; }
- process.env.CHAT_DB_PATH = path.resolve(args.db);
- }
const db = require('../server/db/database');
const db = require('../server/db/database');
+ await db.initDb();
if (args.resolve) {
if (args.resolve) {
- const missing = db.all(`SELECT DISTINCT id FROM (SELECT blocker_id AS id FROM dm_blocks UNION SELECT blocked_id FROM dm_blocks)
- WHERE id NOT IN (SELECT id FROM ctx_users)`).map((r) => r.id);
+ const missing = (await db.all(`SELECT DISTINCT id FROM (SELECT blocker_id AS id FROM dm_blocks UNION SELECT blocked_id FROM dm_blocks) AS people
+ WHERE id NOT IN (SELECT id FROM ctx_users) ORDER BY id`)).map((r) => r.id);
if (missing.length) {
if (missing.length) {
const ctx = require('../server/live-context');
const ctx = require('../server/live-context');
for (let i = 0; i < missing.length; i += 500) await ctx.ensureUsers(missing.slice(i, i + 500));
for (let i = 0; i < missing.length; i += 500) await ctx.ensureUsers(missing.slice(i, i + 500));
}
}
log(`asked Live for ${missing.length} person(s) missing from ctx_users`);
log(`asked Live for ${missing.length} person(s) missing from ctx_users`);
}
}
- const p = plan(db);
+ const p = await plan(db);
const why = Object.entries(p.skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
const why = Object.entries(p.skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
log(`dm_blocks ${p.rows}; pairs for Network ${p.pairs.length}; skipped: ${why}`);
log(`dm_blocks ${p.rows}; pairs for Network ${p.pairs.length}; skipped: ${why}`);
if (p.unmapped_ids.length) log(`Live ids without a Network subject: ${p.unmapped_ids.slice(0, 50).join(', ')}${p.unmapped_ids.length > 50 ? ` (+${p.unmapped_ids.length - 50} more)` : ''}`);
if (p.unmapped_ids.length) log(`Live ids without a Network subject: ${p.unmapped_ids.slice(0, 50).join(', ')}${p.unmapped_ids.length > 50 ? ` (+${p.unmapped_ids.length - 50} more)` : ''}`);
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
* Send whatever is still queued in live_mirror_outbox to Live, once, without starting the service.
* Send whatever is still queued in live_mirror_outbox to Live, once, without starting the service.
* For a rollback when the service cannot run: every change Chat made reaches Live's tables before
* For a rollback when the service cannot run: every change Chat made reaches Live's tables before
* Live becomes the chat authority again (docs/cutover.md). Uses the same env as the service
* Live becomes the chat authority again (docs/cutover.md). Uses the same env as the service
- * (/etc/openvibe/chat.env); LIVE_MIRROR is forced on.
+ * (/etc/openvibe/chat.env: DATABASE_URL, DATABASE_DIRECT_URL); LIVE_MIRROR is forced on.
*
*
* node scripts/mirror-flush.js → { sent, pending, error }
* node scripts/mirror-flush.js → { sent, pending, error }
*/
*/
@@ -15,11 +15,11 @@ const db = require('../server/db/database');
@@ -15,11 +15,11 @@ const db = require('../server/db/database');
const { createMirror } = require('../server/bridge/live-mirror');
const { createMirror } = require('../server/bridge/live-mirror');
(async () => {
(async () => {
- db.initDb({ captureMirror: false });
+ await db.initDb();
const mirror = createMirror({ config });
const mirror = createMirror({ config });
- const before = mirror.pending();
+ const before = await mirror.pending();
const r = await mirror.flush();
const r = await mirror.flush();
console.log(JSON.stringify({ pending_before: before, ...r }, null, 2));
console.log(JSON.stringify({ pending_before: before, ...r }, null, 2));
- db.close();
+ await db.close();
process.exit(r.pending ? 1 : 0);
process.exit(r.pending ? 1 : 0);
})().catch((err) => { console.error(err.message); process.exit(3); });
})().catch((err) => { console.error(err.message); process.exit(3); });
@@ -12,13 +12,18 @@
@@ -12,13 +12,18 @@
* the way test/n-1/service.js says, and every call its client code makes (test/n-1/harness.js finds
* the way test/n-1/service.js says, and every call its client code makes (test/n-1/harness.js finds
* them) is replayed against it; a service with sockets also replays the messages its clients send.
* them) is replayed against it; a service with sockets also replays the messages its clients send.
* Writes test/fixtures/n-1/client.json and worker.json; commit them.
* Writes test/fixtures/n-1/client.json and worker.json; commit them.
+ *
+ * A release on PostgreSQL (plan T3: migrations/, openvibe-sdk/db) boots on a PGlite directory of its own; its
+ * schema is recorded as the migrations it applied (name and text: append-only, so the test rebuilds exactly that
+ * database), and its statements as PostgreSQL received them. A release on SQLite is recorded as before (that needs
+ * better-sqlite3 in node_modules, which this checkout no longer installs).
*/
*/
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
const h = require('../test/n-1/harness');
const h = require('../test/n-1/harness');
const svc = require('../test/n-1/service');
const svc = require('../test/n-1/service');
+const { onPostgres } = require('../test/n-1/engine');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -27,14 +32,15 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -27,14 +32,15 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
const ref = process.argv[2] || 'HEAD';
const ref = process.argv[2] || 'HEAD';
const wt = h.worktree(ROOT, ref);
const wt = h.worktree(ROOT, ref);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-record-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-record-'));
- const dbPath = path.join(tmp, 'db', `${svc.service}.db`);
+ const pg = onPostgres(wt.dir);
+ const dbPath = pg ? path.join(tmp, 'db', 'pglite') : path.join(tmp, 'db', `${svc.service}.db`);
const dataDir = path.join(tmp, 'data');
const dataDir = path.join(tmp, 'data');
const sqlOut = path.join(tmp, 'sql.json');
const sqlOut = path.join(tmp, 'sql.json');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
let server = null;
let server = null;
try {
try {
- console.log(`n-1:record: ${svc.service} at ${wt.sha.slice(0, 12)} (${ref})`);
+ console.log(`n-1:record: ${svc.service} at ${wt.sha.slice(0, 12)} (${ref}), on ${pg ? 'PostgreSQL' : 'SQLite'}`);
// 1. The client: its calls, and the names it reads.
// 1. The client: its calls, and the names it reads.
const files = svc.clientFiles(wt.dir);
const files = svc.clientFiles(wt.dir);
@@ -99,6 +105,33 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -99,6 +105,33 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
server = null;
server = null;
// 3. The schema N-1 left, and the SQL it runs on it.
// 3. The schema N-1 left, and the SQL it runs on it.
+ 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 {
+ const migrations = await h.pgMigrations(db, path.join(wt.dir, 'migrations'));
+ const ledger = {};
+ for (const t of svc.ledgerTables || []) {
+ try { ledger[t] = await db.many(`SELECT * FROM "${t}"`); } catch { /* not there */ }
+ }
+ const ran = fs.existsSync(sqlOut) ? JSON.parse(fs.readFileSync(sqlOut, 'utf8')) : [];
+ // SQL literals in the source compile to what PostgreSQL receives (? and @name → $n), like the ones that ran.
+ const literals = h.sqlLiterals(h.readTree(wt.dir, svc.sqlDirs)).map((s) => { try { return db.prepare(s).source; } catch { return null; } }).filter(Boolean);
+ const candidates = [...new Set([...ran, ...literals].map(h.normalizeSql).filter((s) => /^\s*(SELECT|INSERT|UPDATE|DELETE|WITH)\b/i.test(s)))].sort();
+ const failing = new Set((await h.pgPrepareProblems(db, candidates)).map((p) => p.sql));
+ const statements = candidates.filter((s) => !failing.has(s));
+ console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${migrations.length} migrations`);
+ fs.mkdirSync(OUT, { recursive: true });
+ writeClient();
+ fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, engine: 'postgresql', migrations, ledger, statements }, null, 1)}\n`);
+ console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
+ } finally { await db.close().catch(() => {}); }
+ return;
+ }
+ const Database = require('better-sqlite3');
const db = new Database(dbPath);
const db = new Database(dbPath);
const schema = h.schemaDDL(db);
const schema = h.schemaDDL(db);
const ledger = {};
const ledger = {};
@@ -117,9 +150,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -117,9 +150,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
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`);
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 });
fs.mkdirSync(OUT, { recursive: true });
- 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;
- fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);
+ writeClient();
fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);
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`);
console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
} finally {
} finally {
@@ -614,7 +614,7 @@ function openSocket(url, headers) {
@@ -614,7 +614,7 @@ function openSocket(url, headers) {
};
};
ws.none = async (pred, ms = QUIET_MS) => { await new Promise((r) => setTimeout(r, ms)); return !all.some(pred); };
ws.none = async (pred, ms = QUIET_MS) => { await new Promise((r) => setTimeout(r, ms)); return !all.some(pred); };
ws.sendJson = (o) => ws.send(JSON.stringify(o));
ws.sendJson = (o) => ws.send(JSON.stringify(o));
- ws.once('open', () => resolve(ws));
+ ws.once('open', async () => await resolve(ws));
ws.once('error', reject);
ws.once('error', reject);
ws.once('unexpected-response', (req, res) => reject(new Error(`upgrade refused ${res.statusCode}`)));
ws.once('unexpected-response', (req, res) => reject(new Error(`upgrade refused ${res.statusCode}`)));
});
});
@@ -58,7 +58,7 @@ let _tickInFlight = false;
@@ -58,7 +58,7 @@ let _tickInFlight = false;
let _lastUserPass = 0;
let _lastUserPass = 0;
// ── Small utils ──────────────────────────────────────────────────────────────
// ── Small utils ──────────────────────────────────────────────────────────────
-// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (CURRENT_TIMESTAMP). Match that format.
+// DB timestamps are UTC 'YYYY-MM-DD HH:MM:SS' (ov_now()). Match that format.
function _sqlTime(d) { return new Date(d).toISOString().slice(0, 19).replace('T', ' '); }
function _sqlTime(d) { return new Date(d).toISOString().slice(0, 19).replace('T', ' '); }
function _parseSqlTime(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }
function _parseSqlTime(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }
function _clip(str, n) { str = (str == null ? '' : String(str)).trim(); return str.length > n ? str.slice(0, n) : str; }
function _clip(str, n) { str = (str == null ? '' : String(str)).trim(); return str.length > n ? str.slice(0, n) : str; }
@@ -120,10 +120,10 @@ function _cleanTimeline(arr) {
@@ -120,10 +120,10 @@ function _cleanTimeline(arr) {
// ── GLOBAL ───────────────────────────────────────────────────────────────────
// ── GLOBAL ───────────────────────────────────────────────────────────────────
async function _refreshGlobal() {
async function _refreshGlobal() {
- const prior = db.getChatAiSummary('global', 0, 'global');
+ const prior = await db.getChatAiSummary('global', 0, 'global');
const hw = prior ? (prior.last_message_id || 0) : 0;
const hw = prior ? (prior.last_message_id || 0) : 0;
- const maxId = db.getMaxChatMessageId();
- const newCount = maxId > hw ? db.countChatMessagesSince(hw) : 0;
+ const maxId = await db.getMaxChatMessageId();
+ const newCount = maxId > hw ? await db.countChatMessagesSince(hw) : 0;
if (newCount === 0) return false;
if (newCount === 0) return false;
const ageMs = prior && prior.updated_at ? (Date.now() - _parseSqlTime(prior.updated_at)) : Infinity;
const ageMs = prior && prior.updated_at ? (Date.now() - _parseSqlTime(prior.updated_at)) : Infinity;
@@ -131,14 +131,14 @@ async function _refreshGlobal() {
@@ -131,14 +131,14 @@ async function _refreshGlobal() {
if (newCount < GLOBAL_MSG_THRESHOLD && ageMs < GLOBAL_MAX_AGE_MS) return false;
if (newCount < GLOBAL_MSG_THRESHOLD && ageMs < GLOBAL_MAX_AGE_MS) return false;
const now = Date.now();
const now = Date.now();
- const nthTs = db.getNthRecentChatTs(WINDOW_TARGET_MESSAGES);
+ const nthTs = await db.getNthRecentChatTs(WINDOW_TARGET_MESSAGES);
let startMs = nthTs ? _parseSqlTime(nthTs) : (now - WINDOW_MAX_MS);
let startMs = nthTs ? _parseSqlTime(nthTs) : (now - WINDOW_MAX_MS);
startMs = Math.min(startMs, now - WINDOW_MIN_MS);
startMs = Math.min(startMs, now - WINDOW_MIN_MS);
startMs = Math.max(startMs, now - WINDOW_MAX_MS);
startMs = Math.max(startMs, now - WINDOW_MAX_MS);
const windowStart = _sqlTime(startMs);
const windowStart = _sqlTime(startMs);
const windowLabel = _windowLabel(now - startMs);
const windowLabel = _windowLabel(now - startMs);
- const rows = db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });
+ const rows = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });
if (!rows.length) return false;
if (!rows.length) return false;
const priorMemory = prior ? (prior.memory_json || '') : '';
const priorMemory = prior ? (prior.memory_json || '') : '';
let priorTl = [];
let priorTl = [];
@@ -153,8 +153,8 @@ async function _refreshGlobal() {
@@ -153,8 +153,8 @@ async function _refreshGlobal() {
if (!parsed) return false;
if (!parsed) return false;
const nowIso = _sqlTime(now);
const nowIso = _sqlTime(now);
- db.addChatTimelineEvents('global', 0, _stampAdditions(parsed.timeline, nowIso, now));
- db.upsertChatAiSummary({
+ await db.addChatTimelineEvents('global', 0, _stampAdditions(parsed.timeline, nowIso, now));
+ await db.upsertChatAiSummary({
scope: 'global', subject_id: 0, window: 'global',
scope: 'global', subject_id: 0, window: 'global',
overview: _clip(parsed.recent_overview || '', 2000),
overview: _clip(parsed.recent_overview || '', 2000),
memory_json: _clip(parsed.memory || priorMemory, MEMORY_MAX_CHARS),
memory_json: _clip(parsed.memory || priorMemory, MEMORY_MAX_CHARS),
@@ -172,13 +172,13 @@ async function _refreshGlobal() {
@@ -172,13 +172,13 @@ async function _refreshGlobal() {
// ── PER-USER ─────────────────────────────────────────────────────────────────
// ── PER-USER ─────────────────────────────────────────────────────────────────
async function _refreshUser(uid, maxId) {
async function _refreshUser(uid, maxId) {
- const prior = db.getChatAiSummary('user', uid, 'rolling');
+ const prior = await db.getChatAiSummary('user', uid, 'rolling');
const now = Date.now();
const now = Date.now();
const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);
const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);
- let dayRows = db.getChatMessagesForAi({ sinceTs: dayStart, userId: uid, order: 'asc', limit: MAX_BATCH_MESSAGES });
+ let dayRows = await db.getChatMessagesForAi({ sinceTs: dayStart, userId: uid, order: 'asc', limit: MAX_BATCH_MESSAGES });
let has24h = dayRows.length > 0;
let has24h = dayRows.length > 0;
- if (!dayRows.length) dayRows = db.getChatMessagesForAi({ userId: uid, order: 'desc', limit: 80 });
+ if (!dayRows.length) dayRows = await db.getChatMessagesForAi({ userId: uid, order: 'desc', limit: 80 });
if (!dayRows.length) return false;
if (!dayRows.length) return false;
const uname = dayRows[dayRows.length - 1].username || `user#${uid}`;
const uname = dayRows[dayRows.length - 1].username || `user#${uid}`;
@@ -192,11 +192,11 @@ async function _refreshUser(uid, maxId) {
@@ -192,11 +192,11 @@ async function _refreshUser(uid, maxId) {
if (!parsed) parsed = extractive.profileFrom(dayRows, { name: uname, subjectKind: 'user', priorMemory, has24h, seen: totalSeen, now });
if (!parsed) parsed = extractive.profileFrom(dayRows, { name: uname, subjectKind: 'user', priorMemory, has24h, seen: totalSeen, now });
if (!parsed) return false;
if (!parsed) return false;
- const newCount = db.countChatMessagesSince(prior ? (prior.last_message_id || 0) : 0, uid);
+ const newCount = await db.countChatMessagesSince(prior ? (prior.last_message_id || 0) : 0, uid);
const nowIso = _sqlTime(now);
const nowIso = _sqlTime(now);
const _lastRow = dayRows[dayRows.length - 1] || {};
const _lastRow = dayRows[dayRows.length - 1] || {};
const activityTs = _lastRow.timestamp || _lastRow.created_at || nowIso;
const activityTs = _lastRow.timestamp || _lastRow.created_at || nowIso;
- db.upsertChatAiSummary({
+ await db.upsertChatAiSummary({
scope: 'user', subject_id: uid, window: 'rolling',
scope: 'user', subject_id: uid, window: 'rolling',
overview: JSON.stringify({
overview: JSON.stringify({
today: _clip(parsed.overview_24h || '', 1200),
today: _clip(parsed.overview_24h || '', 1200),
@@ -207,7 +207,7 @@ async function _refreshUser(uid, maxId) {
@@ -207,7 +207,7 @@ async function _refreshUser(uid, maxId) {
timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, activityTs, now),
timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, activityTs, now),
message_count: totalSeen + newCount,
message_count: totalSeen + newCount,
window_message_count: dayRows.length,
window_message_count: dayRows.length,
- last_message_id: maxId || db.getMaxChatMessageId(),
+ last_message_id: maxId || await db.getMaxChatMessageId(),
window_label: has24h ? 'past 24h' : 'recent',
window_label: has24h ? 'past 24h' : 'recent',
window_start: has24h ? dayStart : null,
window_start: has24h ? dayStart : null,
window_end: nowIso,
window_end: nowIso,
@@ -221,7 +221,7 @@ async function _userPass() {
@@ -221,7 +221,7 @@ async function _userPass() {
const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
let candidates = [];
let candidates = [];
try {
try {
- candidates = db.getUsersNeedingChatAi({ threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK });
+ candidates = await db.getUsersNeedingChatAi({ threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK });
} catch (e) { console.warn('[ChatAI] user discovery failed:', e.message); return; }
} catch (e) { console.warn('[ChatAI] user discovery failed:', e.message); return; }
for (const c of candidates) {
for (const c of candidates) {
if (!c.uid) continue;
if (!c.uid) continue;
@@ -232,13 +232,13 @@ async function _userPass() {
@@ -232,13 +232,13 @@ async function _userPass() {
// ── PER RELAY-USER (external platform chatters bridged in) ────────────────────
// ── PER RELAY-USER (external platform chatters bridged in) ────────────────────
async function _refreshRelayUser(ru) {
async function _refreshRelayUser(ru) {
- const prior = db.getChatAiSummary('relay', ru.id, 'rolling');
+ const prior = await db.getChatAiSummary('relay', ru.id, 'rolling');
const now = Date.now();
const now = Date.now();
const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);
const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);
- let dayRows = db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });
+ let dayRows = await db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });
let has24h = dayRows.length > 0;
let has24h = dayRows.length > 0;
- if (!dayRows.length) dayRows = db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, order: 'desc', limit: 80 });
+ if (!dayRows.length) dayRows = await db.getRelayChatMessagesForAi({ platform: ru.platform, rawUsername: ru.username, order: 'desc', limit: 80 });
if (!dayRows.length) return false;
if (!dayRows.length) return false;
const uname = ru.display_name || ru.username;
const uname = ru.display_name || ru.username;
@@ -252,7 +252,7 @@ async function _refreshRelayUser(ru) {
@@ -252,7 +252,7 @@ async function _refreshRelayUser(ru) {
if (!parsed) return false;
if (!parsed) return false;
const nowIso = _sqlTime(now);
const nowIso = _sqlTime(now);
- db.upsertChatAiSummary({
+ await db.upsertChatAiSummary({
scope: 'relay', subject_id: ru.id, window: 'rolling',
scope: 'relay', subject_id: ru.id, window: 'rolling',
overview: JSON.stringify({
overview: JSON.stringify({
today: _clip(parsed.overview_24h || '', 1200),
today: _clip(parsed.overview_24h || '', 1200),
@@ -263,7 +263,7 @@ async function _refreshRelayUser(ru) {
@@ -263,7 +263,7 @@ async function _refreshRelayUser(ru) {
timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),
timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),
message_count: ru.message_count || 0,
message_count: ru.message_count || 0,
window_message_count: dayRows.length,
window_message_count: dayRows.length,
- last_message_id: db.getMaxChatMessageId(),
+ last_message_id: await db.getMaxChatMessageId(),
window_label: has24h ? 'past 24h' : 'recent',
window_label: has24h ? 'past 24h' : 'recent',
window_start: has24h ? dayStart : null,
window_start: has24h ? dayStart : null,
window_end: nowIso,
window_end: nowIso,
@@ -275,7 +275,7 @@ async function _refreshRelayUser(ru) {
@@ -275,7 +275,7 @@ async function _refreshRelayUser(ru) {
async function _relayPass() {
async function _relayPass() {
const lookbackIso = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
const lookbackIso = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
let candidates = [];
let candidates = [];
- try { candidates = db.getRelayUsersNeedingChatAi({ lookbackIso, threshold: 8, limit: USER_MAX_PER_TICK }); }
+ try { candidates = await db.getRelayUsersNeedingChatAi({ lookbackIso, threshold: 8, limit: USER_MAX_PER_TICK }); }
catch (e) { console.warn('[ChatAI] relay discovery failed:', e.message); return; }
catch (e) { console.warn('[ChatAI] relay discovery failed:', e.message); return; }
for (const ru of candidates) {
for (const ru of candidates) {
if (!ru.id) continue;
if (!ru.id) continue;
@@ -288,13 +288,13 @@ async function _relayPass() {
@@ -288,13 +288,13 @@ async function _relayPass() {
async function _refreshAnon(anonId) {
async function _refreshAnon(anonId) {
const subjectId = db.anonSubjectId(anonId);
const subjectId = db.anonSubjectId(anonId);
if (!subjectId) return false;
if (!subjectId) return false;
- const prior = db.getChatAiSummary('anon', subjectId, 'rolling');
+ const prior = await db.getChatAiSummary('anon', subjectId, 'rolling');
const now = Date.now();
const now = Date.now();
const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);
const dayStart = _sqlTime(now - 24 * 60 * 60 * 1000);
- let dayRows = db.getAnonChatMessagesForAi({ anonId, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });
+ let dayRows = await db.getAnonChatMessagesForAi({ anonId, sinceTs: dayStart, order: 'asc', limit: MAX_BATCH_MESSAGES });
let has24h = dayRows.length > 0;
let has24h = dayRows.length > 0;
- if (!dayRows.length) dayRows = db.getAnonChatMessagesForAi({ anonId, order: 'desc', limit: 80 });
+ if (!dayRows.length) dayRows = await db.getAnonChatMessagesForAi({ anonId, order: 'desc', limit: 80 });
if (!dayRows.length) return false;
if (!dayRows.length) return false;
const priorMemory = prior ? (prior.memory_json || '') : '';
const priorMemory = prior ? (prior.memory_json || '') : '';
@@ -306,7 +306,7 @@ async function _refreshAnon(anonId) {
@@ -306,7 +306,7 @@ async function _refreshAnon(anonId) {
if (!parsed) return false;
if (!parsed) return false;
const nowIso = _sqlTime(now);
const nowIso = _sqlTime(now);
- db.upsertChatAiSummary({
+ await db.upsertChatAiSummary({
scope: 'anon', subject_id: subjectId, window: 'rolling',
scope: 'anon', subject_id: subjectId, window: 'rolling',
overview: JSON.stringify({
overview: JSON.stringify({
today: _clip(parsed.overview_24h || '', 1200),
today: _clip(parsed.overview_24h || '', 1200),
@@ -317,7 +317,7 @@ async function _refreshAnon(anonId) {
@@ -317,7 +317,7 @@ async function _refreshAnon(anonId) {
timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),
timeline_json: _mergeTimeline(prior ? prior.timeline_json : '[]', parsed.timeline, nowIso, now),
message_count: dayRows.length,
message_count: dayRows.length,
window_message_count: dayRows.length,
window_message_count: dayRows.length,
- last_message_id: db.getMaxChatMessageId(),
+ last_message_id: await db.getMaxChatMessageId(),
window_label: has24h ? 'past 24h' : 'recent',
window_label: has24h ? 'past 24h' : 'recent',
window_start: has24h ? dayStart : null,
window_start: has24h ? dayStart : null,
window_end: nowIso,
window_end: nowIso,
@@ -331,7 +331,7 @@ async function _anonPass() {
@@ -331,7 +331,7 @@ async function _anonPass() {
const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
const sinceTs = _sqlTime(Date.now() - USER_DISCOVERY_LOOKBACK_DAYS * 86400000);
let candidates = [];
let candidates = [];
try {
try {
- candidates = db.getAnonsNeedingChatAi({ threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK });
+ candidates = await db.getAnonsNeedingChatAi({ threshold: USER_MSG_THRESHOLD, staleCutoffIso: staleCutoff, sinceTs, limit: USER_MAX_PER_TICK });
} catch (e) { console.warn('[ChatAI] anon discovery failed:', e.message); return; }
} catch (e) { console.warn('[ChatAI] anon discovery failed:', e.message); return; }
for (const c of candidates) {
for (const c of candidates) {
if (!c.anon_id) continue;
if (!c.anon_id) continue;
@@ -365,12 +365,12 @@ async function _tick() {
@@ -365,12 +365,12 @@ async function _tick() {
// One-time: backfill the growing timeline log from the existing summary JSON.
// One-time: backfill the growing timeline log from the existing summary JSON.
async function _seedTimelineEvents() {
async function _seedTimelineEvents() {
try {
try {
- if ((db.getChatTimelineEvents({ scope: 'global', limit: 1 }) || []).length) return;
- const row = db.getChatAiSummary('global', 0, 'global');
+ if ((await db.getChatTimelineEvents({ scope: 'global', limit: 1 }) || []).length) return;
+ const row = await db.getChatAiSummary('global', 0, 'global');
if (!row) return;
if (!row) return;
let tl = []; try { tl = JSON.parse(row.timeline_json || '[]'); } catch { tl = []; }
let tl = []; try { tl = JSON.parse(row.timeline_json || '[]'); } catch { tl = []; }
const cleaned = _cleanTimeline(tl);
const cleaned = _cleanTimeline(tl);
- if (cleaned.length) { db.addChatTimelineEvents('global', 0, cleaned); console.log(`[ChatAI] Seeded ${cleaned.length} timeline event(s)`); }
+ if (cleaned.length) { await db.addChatTimelineEvents('global', 0, cleaned); console.log(`[ChatAI] Seeded ${cleaned.length} timeline event(s)`); }
} catch { /* */ }
} catch { /* */ }
}
}
@@ -406,8 +406,8 @@ function _parseOverviews(row) {
@@ -406,8 +406,8 @@ function _parseOverviews(row) {
};
};
}
}
-function getGlobalInsight() {
- const row = db.getChatAiSummary('global', 0, 'global');
+async function getGlobalInsight() {
+ const row = await db.getChatAiSummary('global', 0, 'global');
if (!row) return null;
if (!row) return null;
let timeline = [];
let timeline = [];
try { timeline = _cleanTimeline(JSON.parse(row.timeline_json || '[]')); } catch { /* */ }
try { timeline = _cleanTimeline(JSON.parse(row.timeline_json || '[]')); } catch { /* */ }
@@ -422,18 +422,18 @@ function getGlobalInsight() {
@@ -422,18 +422,18 @@ function getGlobalInsight() {
};
};
}
}
-function getUserInsight(userId) {
- const row = db.getChatAiSummary('user', userId, 'rolling');
+async function getUserInsight(userId) {
+ const row = await db.getChatAiSummary('user', userId, 'rolling');
return row ? _parseOverviews(row) : null;
return row ? _parseOverviews(row) : null;
}
}
-function getRelayUserInsight(relayId) {
- const row = db.getChatAiSummary('relay', relayId, 'rolling');
+async function getRelayUserInsight(relayId) {
+ const row = await db.getChatAiSummary('relay', relayId, 'rolling');
return row ? _parseOverviews(row) : null;
return row ? _parseOverviews(row) : null;
}
}
-function getAnonInsight(anonId) {
- const row = db.getChatAiSummary('anon', db.anonSubjectId(anonId), 'rolling');
+async function getAnonInsight(anonId) {
+ const row = await db.getChatAiSummary('anon', db.anonSubjectId(anonId), 'rolling');
return row ? _parseOverviews(row) : null;
return row ? _parseOverviews(row) : null;
}
}
@@ -105,10 +105,10 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -105,10 +105,10 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
// without; `live_sync` is optional, because chat keeps flowing from warm caches while Live is
// without; `live_sync` is optional, because chat keeps flowing from warm caches while Live is
// down (live-context.js), so a stale sync degrades the service instead of taking it out.
// down (live-context.js), so a stale sync degrades the service instead of taking it out.
- function timed(fn) {
+ async function timed(fn) {
const t0 = process.hrtime.bigint();
const t0 = process.hrtime.bigint();
let out;
let out;
- try { out = fn(); } catch (err) { out = { ok: false, error: String((err && err.message) || err).split('\n')[0].slice(0, 200) }; }
+ try { out = await fn(); } catch (err) { out = { ok: false, error: String((err && err.message) || err).split('\n')[0].slice(0, 200) }; }
const { ok, error, detail } = out;
const { ok, error, detail } = out;
return {
return {
status: ok ? 'ok' : 'fail', required: false,
status: ok ? 'ok' : 'fail', required: false,
@@ -132,8 +132,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -132,8 +132,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
app.get('/ready', async (req, res) => {
app.get('/ready', async (req, res) => {
const checks = {
const checks = {
// A real read of a chat table (MAX of the rowid is an index seek, not a scan).
// A real read of a chat table (MAX of the rowid is an index seek, not a scan).
- db: { ...timed(() => ({ ok: true, detail: { max_message_id: db.get('SELECT MAX(id) AS id FROM chat_messages').id } })), required: true },
- live_sync: timed(() => {
+ db: { ...await timed(async () => ({ ok: true, detail: { max_message_id: (await db.get('SELECT MAX(id) AS id FROM chat_messages')).id } })), required: true },
+ live_sync: await timed(() => {
const s = ctx.syncStatus(config.live.syncStaleMs);
const s = ctx.syncStatus(config.live.syncStaleMs);
const detail = { ...s, threshold_ms: config.live.syncStaleMs };
const detail = { ...s, threshold_ms: config.live.syncStaleMs };
if (s.last_success_at === null) return { ok: false, error: 'no successful Live sync since start', detail };
if (s.last_success_at === null) return { ok: false, error: 'no successful Live sync since start', detail };
@@ -152,7 +152,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
@@ -152,7 +152,7 @@ function createApp({ chatServer, bridge, mirror, relay, events = null, callServe
const degraded = Object.keys(checks).filter((k) => !checks[k].required && checks[k].status !== 'ok');
const degraded = Object.keys(checks).filter((k) => !checks[k].required && checks[k].status !== 'ok');
const ready = failed.length === 0;
const ready = failed.length === 0;
let pending = null;
let pending = null;
- if (mirror && checks.db.status === 'ok') { try { pending = mirror.pending(); } catch { /* reported by db */ } }
+ if (mirror && checks.db.status === 'ok') { try { pending = await mirror.pending(); } catch { /* reported by db */ } }
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.status(ready ? 200 : 503).json({
res.status(ready ? 200 : 503).json({
ready,
ready,
@@ -179,8 +179,8 @@ async function optionalAuth(req, res, next) {
@@ -179,8 +179,8 @@ async function optionalAuth(req, res, next) {
/**
/**
* Express middleware — requires admin role
* Express middleware — requires admin role
*/
*/
-function requireAdmin(req, res, next) {
- requireAuth(req, res, () => {
+async function requireAdmin(req, res, next) {
+ await requireAuth(req, res, () => {
if (!require('./permissions').isAdmin(req.user)) {
if (!require('./permissions').isAdmin(req.user)) {
return res.status(403).json({ error: 'Admin access required' });
return res.status(403).json({ error: 'Admin access required' });
}
}
@@ -51,10 +51,10 @@ function verify(token, publicKey, { issuer = config.networkUrl, now = Date.now()
@@ -51,10 +51,10 @@ function verify(token, publicKey, { issuer = config.networkUrl, now = Date.now()
}
}
/** The projected user for a verified token, or null when the projection cannot say which one. */
/** The projected user for a verified token, or null when the projection cannot say which one. */
-function userFor(claims) {
+async function userFor(claims) {
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
if (!subject) return null;
if (!subject) return null;
- const rows = db.all('SELECT * FROM ctx_users WHERE subject_id = ? LIMIT 2', [subject]);
+ const rows = await db.all('SELECT * FROM ctx_users WHERE subject_id = ? LIMIT 2', [subject]);
if (rows.length !== 1) return null;
if (rows.length !== 1) return null;
const row = rows[0];
const row = rows[0];
const user = {
const user = {
@@ -82,19 +82,19 @@ async function authenticate(token) {
@@ -82,19 +82,19 @@ async function authenticate(token) {
const u = await ctx.authenticate(token);
const u = await ctx.authenticate(token);
// Live resolved a Network token (first visit, another key): the same cutoff applies.
// Live resolved a Network token (first visit, another key): the same cutoff applies.
const iat = u && u.auth_source !== 'api_token' ? tokenIat(token) : null;
const iat = u && u.auth_source !== 'api_token' ? tokenIat(token) : null;
- if (iat != null && iat * 1000 < revocations.cutoffFor(u.subject_id)) return refuse(token);
+ if (iat != null && iat * 1000 < await revocations.cutoffFor(u.subject_id)) return refuse(token);
return u;
return u;
};
};
- if (String(token).startsWith('hbt_')) return viaLive();
+ if (String(token).startsWith('hbt_')) return await viaLive();
const key = await serviceAuth.ensureKey();
const key = await serviceAuth.ensureKey();
- if (!key) return viaLive();
+ if (!key) return await viaLive();
const r = verify(token, key);
const r = verify(token, key);
// Only a token signed by the key we hold is decided here; anything else is Live's to judge.
// Only a token signed by the key we hold is decided here; anything else is Live's to judge.
- if (!r.ok) return r.reason === 'signature' || r.reason === 'malformed' ? viaLive() : refuse(token);
+ if (!r.ok) return r.reason === 'signature' || r.reason === 'malformed' ? await viaLive() : refuse(token);
// Signed out everywhere, password changed, banned…: Network's cutoff for this person (WS-B task 4).
// Signed out everywhere, password changed, banned…: Network's cutoff for this person (WS-B task 4).
- if (revocations.isRevoked(r.claims)) return refuse(token);
- const user = userFor(r.claims);
- if (!user) return viaLive();
+ if (await revocations.isRevoked(r.claims)) return refuse(token);
+ const user = await userFor(r.claims);
+ if (!user) return await viaLive();
stats.local++;
stats.local++;
return user;
return user;
}
}
@@ -58,35 +58,35 @@ function isStreamer(user) {
@@ -58,35 +58,35 @@ function isStreamer(user) {
/**
/**
* Is this user a channel moderator for the given channel?
* Is this user a channel moderator for the given channel?
*/
*/
-function isChannelMod(user, channelId) {
+async function isChannelMod(user, channelId) {
if (!user?.id || !channelId) return false;
if (!user?.id || !channelId) return false;
- return !!ctx.isChannelModerator(user.id, channelId);
+ return !!await ctx.isChannelModerator(user.id, channelId);
}
}
/**
/**
* Is this user the owner of the given channel?
* Is this user the owner of the given channel?
*/
*/
-function isChannelOwner(user, channelId) {
+async function isChannelOwner(user, channelId) {
if (!user?.id || !channelId) return false;
if (!user?.id || !channelId) return false;
- const channel = ctx.getChannelById(channelId);
+ const channel = await ctx.getChannelById(channelId);
return channel?.user_id === user.id;
return channel?.user_id === user.id;
}
}
/**
/**
* Does a stream belong to this user?
* Does a stream belong to this user?
*/
*/
-function isStreamOwner(user, streamId) {
+async function isStreamOwner(user, streamId) {
if (!user?.id || !streamId) return false;
if (!user?.id || !streamId) return false;
- const stream = ctx.getStreamById(streamId);
+ const stream = await ctx.getStreamById(streamId);
return stream?.user_id === user.id;
return stream?.user_id === user.id;
}
}
/**
/**
* Get the channel_id for a given stream.
* Get the channel_id for a given stream.
*/
*/
-function getChannelIdForStream(streamId) {
+async function getChannelIdForStream(streamId) {
if (!streamId) return null;
if (!streamId) return null;
- const stream = ctx.getStreamById(streamId);
+ const stream = await ctx.getStreamById(streamId);
return stream?.channel_id || null;
return stream?.channel_id || null;
}
}
@@ -97,11 +97,11 @@ function getChannelIdForStream(streamId) {
@@ -97,11 +97,11 @@ function getChannelIdForStream(streamId) {
*
*
* True for: admin, global_mod, channel owner, channel mod
* True for: admin, global_mod, channel owner, channel mod
*/
*/
-function canModerateChannel(user, channelId) {
+async function canModerateChannel(user, channelId) {
if (!user) return false;
if (!user) return false;
if (can(user, 'staff.moderation.chat')) return true;
if (can(user, 'staff.moderation.chat')) return true;
- if (isChannelOwner(user, channelId)) return true;
- return isChannelMod(user, channelId);
+ if (await isChannelOwner(user, channelId)) return true;
+ return await isChannelMod(user, channelId);
}
}
/**
/**
@@ -109,12 +109,12 @@ function canModerateChannel(user, channelId) {
@@ -109,12 +109,12 @@ function canModerateChannel(user, channelId) {
*
*
* Resolves stream → channel, then checks channel moderation.
* Resolves stream → channel, then checks channel moderation.
*/
*/
-function canModerateStream(user, streamId) {
+async function canModerateStream(user, streamId) {
if (!user) return false;
if (!user) return false;
if (can(user, 'staff.moderation.chat')) return true;
if (can(user, 'staff.moderation.chat')) return true;
- if (isStreamOwner(user, streamId)) return true;
- const channelId = getChannelIdForStream(streamId);
- if (channelId && isChannelMod(user, channelId)) return true;
+ if (await isStreamOwner(user, streamId)) return true;
+ const channelId = await getChannelIdForStream(streamId);
+ if (channelId && await isChannelMod(user, channelId)) return true;
return false;
return false;
}
}
@@ -122,8 +122,8 @@ function canModerateStream(user, streamId) {
@@ -122,8 +122,8 @@ function canModerateStream(user, streamId) {
* Can this user moderate a call on a specific stream?
* Can this user moderate a call on a specific stream?
* Same rules as chat moderation.
* Same rules as chat moderation.
*/
*/
-function canModerateCall(user, streamId) {
- return canModerateStream(user, streamId);
+async function canModerateCall(user, streamId) {
+ return await canModerateStream(user, streamId);
}
}
/**
/**
@@ -11,26 +11,17 @@
@@ -11,26 +11,17 @@
const db = require('../db/database');
const db = require('../db/database');
-let ready = false;
const cache = new Map(); // subject → valid_after ms (0 = none known)
const cache = new Map(); // subject → valid_after ms (0 = none known)
-function ensureSchema() {
- if (ready) return;
- db.getDb().exec(`CREATE TABLE IF NOT EXISTS token_revocations (
- subject_id TEXT PRIMARY KEY,
- valid_after_ms INTEGER NOT NULL,
- reason TEXT,
- updated_at INTEGER NOT NULL
- )`);
- ready = true;
-}
+// The table is in migrations/0001_initial.sql (PostgreSQL); nothing is created at runtime. Kept for callers.
+function ensureSchema() {}
/** The cutoff for a subject in ms, 0 when none. */
/** The cutoff for a subject in ms, 0 when none. */
-function cutoffFor(subject) {
+async function cutoffFor(subject) {
if (!subject) return 0;
if (!subject) return 0;
if (cache.has(subject)) return cache.get(subject);
if (cache.has(subject)) return cache.get(subject);
ensureSchema();
ensureSchema();
- const row = db.get('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?', [subject]);
+ const row = await db.get('SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?', [subject]);
const ms = row ? Number(row.valid_after_ms) || 0 : 0;
const ms = row ? Number(row.valid_after_ms) || 0 : 0;
if (cache.size > 50000) cache.clear();
if (cache.size > 50000) cache.clear();
cache.set(subject, ms);
cache.set(subject, ms);
@@ -38,11 +29,11 @@ function cutoffFor(subject) {
@@ -38,11 +29,11 @@ function cutoffFor(subject) {
}
}
/** Keep the later cutoff (events can arrive out of order). Returns true when it moved forward. */
/** Keep the later cutoff (events can arrive out of order). Returns true when it moved forward. */
-function record(subject, validAfterMs, reason = null, now = Date.now()) {
+async function record(subject, validAfterMs, reason = null, now = Date.now()) {
ensureSchema();
ensureSchema();
- const current = cutoffFor(subject);
+ const current = await cutoffFor(subject);
if (!(validAfterMs > current)) return false;
if (!(validAfterMs > current)) return false;
- db.run(`INSERT INTO token_revocations (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?)
+ await db.run(`INSERT INTO token_revocations (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at
ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at
WHERE excluded.valid_after_ms > token_revocations.valid_after_ms`, [subject, validAfterMs, reason, now]);
WHERE excluded.valid_after_ms > token_revocations.valid_after_ms`, [subject, validAfterMs, reason, now]);
cache.set(subject, validAfterMs);
cache.set(subject, validAfterMs);
@@ -50,12 +41,12 @@ function record(subject, validAfterMs, reason = null, now = Date.now()) {
@@ -50,12 +41,12 @@ function record(subject, validAfterMs, reason = null, now = Date.now()) {
}
}
/** Was a token with these claims issued before its subject's cutoff? */
/** Was a token with these claims issued before its subject's cutoff? */
-function isRevoked(claims) {
+async function isRevoked(claims) {
if (!claims || typeof claims.iat !== 'number') return false;
if (!claims || typeof claims.iat !== 'number') return false;
- return claims.iat * 1000 < cutoffFor(claims.subject_id);
+ return claims.iat * 1000 < await cutoffFor(claims.subject_id);
}
}
/** For tests: forget the in-memory cache (the table stays). */
/** For tests: forget the in-memory cache (the table stays). */
-function _reset() { cache.clear(); ready = false; }
+function _reset() { cache.clear(); }
module.exports = { ensureSchema, cutoffFor, record, isRevoked, _reset };
module.exports = { ensureSchema, cutoffFor, record, isRevoked, _reset };
@@ -118,25 +118,25 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -118,25 +118,25 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
const router = express.Router();
const router = express.Router();
const refs = new Map(); // `${boot}|${ref}` → { id, at }
const refs = new Map(); // `${boot}|${ref}` → { id, at }
- function sweepRefs() {
+ async function sweepRefs() {
const cutoff = Date.now() - REF_TTL_MS;
const cutoff = Date.now() - REF_TTL_MS;
for (const [k, v] of refs) if (v.at < cutoff) refs.delete(k);
for (const [k, v] of refs) if (v.at < cutoff) refs.delete(k);
- try { db.run('DELETE FROM bridge_applied WHERE applied_at < ?', [Date.now() - 7 * 24 * 3600 * 1000]); } catch { /* */ }
- try { db.run('DELETE FROM bridge_refs WHERE at < ?', [Date.now() - REF_KEEP_MS]); } catch { /* */ }
+ try { await db.run('DELETE FROM bridge_applied WHERE applied_at < ?', [Date.now() - 7 * 24 * 3600 * 1000]); } catch { /* */ }
+ try { await db.run('DELETE FROM bridge_refs WHERE at < ?', [Date.now() - REF_KEEP_MS]); } catch { /* */ }
}
}
- function rememberRef(boot, ref, id) {
+ async function rememberRef(boot, ref, id) {
const at = Date.now();
const at = Date.now();
refs.set(`${boot}|${ref}`, { id, at });
refs.set(`${boot}|${ref}`, { id, at });
- db.run('INSERT INTO bridge_refs (boot, ref, id, at) VALUES (?, ?, ?, ?) ON CONFLICT(boot, ref) DO UPDATE SET id = excluded.id, at = excluded.at', [boot, ref, id, at]);
+ await db.run('INSERT INTO bridge_refs (boot, ref, id, at) VALUES (?, ?, ?, ?) ON CONFLICT(boot, ref) DO UPDATE SET id = excluded.id, at = excluded.at', [boot, ref, id, at]);
}
}
/** The real id for a placeholder of this Live boot: memory first, then bridge_refs (a restart). */
/** The real id for a placeholder of this Live boot: memory first, then bridge_refs (a restart). */
- function lookupRef(boot, ref) {
+ async function lookupRef(boot, ref) {
const hit = refs.get(`${boot}|${ref}`);
const hit = refs.get(`${boot}|${ref}`);
if (hit) return hit.id;
if (hit) return hit.id;
let row = null;
let row = null;
- try { row = db.get('SELECT id FROM bridge_refs WHERE boot = ? AND ref = ?', [boot, ref]); } catch { row = null; }
+ try { row = await db.get('SELECT id FROM bridge_refs WHERE boot = ? AND ref = ?', [boot, ref]); } catch { row = null; }
if (!row) return null;
if (!row) return null;
refs.set(`${boot}|${ref}`, { id: row.id, at: Date.now() });
refs.set(`${boot}|${ref}`, { id: row.id, at: Date.now() });
return row.id;
return row.id;
@@ -144,20 +144,20 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -144,20 +144,20 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
const sweep = setInterval(sweepRefs, 60_000);
const sweep = setInterval(sweepRefs, 60_000);
if (sweep.unref) sweep.unref();
if (sweep.unref) sweep.unref();
- function mapRefs(value, boot) {
+ async function mapRefs(value, boot) {
if (typeof value === 'number') {
if (typeof value === 'number') {
- if (value <= REF_MIN) { const id = lookupRef(boot, value); return id != null ? id : value; }
+ if (value <= REF_MIN) { const id = await lookupRef(boot, value); return id != null ? id : value; }
return value;
return value;
}
}
if (typeof value === 'string') {
if (typeof value === 'string') {
const m = /^(m|ov-)(-\d{13,})$/.exec(value);
const m = /^(m|ov-)(-\d{13,})$/.exec(value);
- if (m) { const id = lookupRef(boot, Number(m[2])); return id != null ? `${m[1]}${id}` : value; }
+ if (m) { const id = await lookupRef(boot, Number(m[2])); return id != null ? `${m[1]}${id}` : value; }
return value;
return value;
}
}
- if (Array.isArray(value)) return value.map((v) => mapRefs(v, boot));
+ if (Array.isArray(value)) return (await Promise.all(value.map(async (v) => await mapRefs(v, boot))));
if (value && typeof value === 'object') {
if (value && typeof value === 'object') {
const out = {};
const out = {};
- for (const [k, v] of Object.entries(value)) out[k] = mapRefs(v, boot);
+ for (const [k, v] of Object.entries(value)) out[k] = await mapRefs(v, boot);
return out;
return out;
}
}
return value;
return value;
@@ -169,24 +169,24 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -169,24 +169,24 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
};
};
async function runOp(op, rawArgs, boot, ref, key) {
async function runOp(op, rawArgs, boot, ref, key) {
- const args = mapRefs(Array.isArray(rawArgs) ? rawArgs : [], boot);
+ const args = await mapRefs(Array.isArray(rawArgs) ? rawArgs : [], boot);
if (op === 'db') {
if (op === 'db') {
const [fn, ...fnArgs] = args;
const [fn, ...fnArgs] = args;
if (!DB_OPS.has(fn) || typeof db[fn] !== 'function') throw new Error(`db.${fn} is not a bridge write`);
if (!DB_OPS.has(fn) || typeof db[fn] !== 'function') throw new Error(`db.${fn} is not a bridge write`);
// saveChatMessage derives the channel from the stream: have the stream first.
// saveChatMessage derives the channel from the stream: have the stream first.
if (fn === 'saveChatMessage' && fnArgs[0] && fnArgs[0].stream_id) await ctx.ensureStream(fnArgs[0].stream_id);
if (fn === 'saveChatMessage' && fnArgs[0] && fnArgs[0].stream_id) await ctx.ensureStream(fnArgs[0].stream_id);
- const remember = (result) => {
- if (ref != null && result && result.lastInsertRowid != null) rememberRef(boot, ref, Number(result.lastInsertRowid));
+ const remember = async (result) => {
+ if (ref != null && result && result.lastInsertRowid != null) await rememberRef(boot, ref, Number(result.lastInsertRowid));
return result;
return result;
};
};
- if (!key) return plain(remember(db[fn](...fnArgs)));
+ if (!key) return plain(await remember(await db[fn](...fnArgs)));
// Applied once per idempotency key, in the same transaction as the write.
// Applied once per idempotency key, in the same transaction as the write.
- return db.transaction(() => {
- const done = db.get('SELECT result FROM bridge_applied WHERE key = ?', [key]);
- if (done) { const prev = done.result ? JSON.parse(done.result) : null; return remember(prev); }
- const result = plain(db[fn](...fnArgs));
- db.run('INSERT INTO bridge_applied (key, result, applied_at) VALUES (?, ?, ?)', [key, JSON.stringify(result === undefined ? null : result), Date.now()]);
- return remember(result);
+ return await db.tx(async () => {
+ const done = await db.get('SELECT result FROM bridge_applied WHERE key = ?', [key]);
+ if (done) { const prev = done.result ? JSON.parse(done.result) : null; return await remember(prev); }
+ const result = plain(await db[fn](...fnArgs));
+ await db.run('INSERT INTO bridge_applied (key, result, applied_at) VALUES (?, ?, ?)', [key, JSON.stringify(result === undefined ? null : result), Date.now()]);
+ return await remember(result);
});
});
}
}
if (Object.prototype.hasOwnProperty.call(SERVER_OPS, op)) {
if (Object.prototype.hasOwnProperty.call(SERVER_OPS, op)) {
@@ -195,25 +195,25 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -195,25 +195,25 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
if (op === 'triggerChannelSound') {
if (op === 'triggerChannelSound') {
// (ws, client, stream, command, args, relay) — Live passes no socket.
// (ws, client, stream, command, args, relay) — Live passes no socket.
const [, client, stream, command, soundArgs, relay] = args;
const [, client, stream, command, soundArgs, relay] = args;
- return plain(chatServer.triggerChannelSound(null, client || {}, stream, command, soundArgs || [], relay || null));
+ return plain(await chatServer.triggerChannelSound(null, client || {}, stream, command, soundArgs || [], relay || null));
}
}
- return plain(chatServer[op](...args));
+ return plain(await chatServer[op](...args));
}
}
switch (op) {
switch (op) {
case 'broadcastAllRaw': {
case 'broadcastAllRaw': {
// Live code that iterated chatServer.clients and sent a pre-serialized payload.
// Live code that iterated chatServer.clients and sent a pre-serialized payload.
let data;
let data;
try { data = JSON.parse(String(args[0])); } catch { throw new Error('broadcastAllRaw: not JSON'); }
try { data = JSON.parse(String(args[0])); } catch { throw new Error('broadcastAllRaw: not JSON'); }
- return plain(chatServer.broadcastAll(data));
+ return plain(await chatServer.broadcastAll(data));
}
}
case 'deployNotice':
case 'deployNotice':
- return require('../chat/deploy-notice').announceCommits({ db, chatServer, commits: args[0] });
+ return await require('../chat/deploy-notice').announceCommits({ db, chatServer, commits: args[0] });
case 'playAlertSound': {
case 'playAlertSound': {
// Live's donation / goal-reached alerts: Live says "play the alert for channel X"
// Live's donation / goal-reached alerts: Live says "play the alert for channel X"
// (streamerId, streamId, kind), Chat resolves the sound from its own settings row and
// (streamerId, streamId, kind), Chat resolves the sound from its own settings row and
// broadcasts it to the channel room. Replaces Live reading the file + sending base64.
// broadcasts it to the channel room. Replaces Live reading the file + sending base64.
const [streamerId, streamId, kind] = args;
const [streamerId, streamId, kind] = args;
- return require('../chat/alert-sounds').playAlertSound(chatServer, Number(streamerId) || 0, streamId != null ? Number(streamId) : null, kind === 'goal' ? 'goal' : 'donation');
+ return await require('../chat/alert-sounds').playAlertSound(chatServer, Number(streamerId) || 0, streamId != null ? Number(streamId) : null, kind === 'goal' ? 'goal' : 'donation');
}
}
case 'userChanged':
case 'userChanged':
ctx.invalidateUser(args[0]);
ctx.invalidateUser(args[0]);
@@ -222,7 +222,7 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -222,7 +222,7 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
case 'invalidate': {
case 'invalidate': {
// Live wrote data Chat caches (channel moderators/settings, IP approvals, bans, a user).
// Live wrote data Chat caches (channel moderators/settings, IP approvals, bans, a user).
const [kind, id] = args;
const [kind, id] = args;
- if (kind === 'channel') ctx.invalidateChannel(id);
+ if (kind === 'channel') await ctx.invalidateChannel(id);
else if (kind === 'approvals') ctx.invalidateApprovals(id);
else if (kind === 'approvals') ctx.invalidateApprovals(id);
else if (kind === 'bans') await ctx.invalidateBans();
else if (kind === 'bans') await ctx.invalidateBans();
else if (kind === 'user') ctx.invalidateUser(id);
else if (kind === 'user') ctx.invalidateUser(id);
@@ -256,7 +256,7 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -256,7 +256,7 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
res.json({ ok: true, results });
res.json({ ok: true, results });
});
});
- router.get('/presence', serviceAuth.guard('chat.presence.read'), (req, res) => {
+ router.get('/presence', serviceAuth.guard('chat.presence.read'), async (req, res) => {
const streams = {};
const streams = {};
const users = [];
const users = [];
const anons = [];
const anons = [];
@@ -270,7 +270,7 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
@@ -270,7 +270,7 @@ function createBridge({ chatServer, mirror = null, config = require('../config')
at: new Date().toISOString(),
at: new Date().toISOString(),
total: chatServer.getTotalConnections(),
total: chatServer.getTotalConnections(),
streams,
streams,
- slow_mode: Object.fromEntries(chatServer.slowModeByStream),
+ slow_mode: Object.fromEntries(await chatServer.slowModeByStream()),
users,
users,
anons,
anons,
});
});
@@ -6,8 +6,9 @@
@@ -6,8 +6,9 @@
* and rollback means Live becomes the authority again. So every change Chat makes to its tables
* and rollback means Live becomes the authority again. So every change Chat makes to its tables
* (C-02) is copied into Live's tables of the same name, same ids:
* (C-02) is copied into Live's tables of the same name, same ids:
*
*
- * - this connection's TEMP triggers (db.initDb({ captureMirror: true })) record each change in
- * live_mirror_outbox — writes by the importer (rows that came from Live) are never recorded;
+ * - PostgreSQL triggers on the twelve tables (migrations/0001_initial.sql) record each change in
+ * live_mirror_outbox in the writing transaction — skipped where the transaction set ov.mirror_skip = '1'
+ * (the importers: rows that came from Live are never recorded);
* - this relay sends batches to Live's POST /internal/chat-effects/mirror (capability
* - this relay sends batches to Live's POST /internal/chat-effects/mirror (capability
* live.chat_mirror.write) as { changes: [{ table, op: 'upsert', row } | { table, op: 'delete', pk }] }
* live.chat_mirror.write) as { changes: [{ table, op: 'upsert', row } | { table, op: 'delete', pk }] }
* with the row as it is NOW (so repeated changes to one row collapse into one upsert);
* with the row as it is NOW (so repeated changes to one row collapse into one upsert);
@@ -26,14 +27,30 @@ const BATCH = 200;
@@ -26,14 +27,30 @@ const BATCH = 200;
const MAX_BODY = 800 * 1024; // Live parses JSON bodies up to 1 MB
const MAX_BODY = 800 * 1024; // Live parses JSON bodies up to 1 MB
const SCOPE = 'live.chat_mirror.write';
const SCOPE = 'live.chat_mirror.write';
+// The row as it is now, per mirrored table (static statements, keyed as db.CHAT_TABLES).
+const ROW_SQL = {
+ chat_messages: 'SELECT * FROM chat_messages WHERE id = ?',
+ dm_conversations: 'SELECT * FROM dm_conversations WHERE id = ?',
+ dm_participants: 'SELECT * FROM dm_participants WHERE id = ?',
+ dm_messages: 'SELECT * FROM dm_messages WHERE id = ?',
+ dm_blocks: 'SELECT * FROM dm_blocks WHERE id = ?',
+ tts_voice_overrides: 'SELECT * FROM tts_voice_overrides WHERE identity_key = ?',
+ channel_sounds: 'SELECT * FROM channel_sounds WHERE id = ?',
+ relay_users: 'SELECT * FROM relay_users WHERE id = ?',
+ hidden_relay_users: 'SELECT * FROM hidden_relay_users WHERE id = ?',
+ pending_ip_messages: 'SELECT * FROM pending_ip_messages WHERE id = ?',
+ stream_first_chats: 'SELECT * FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',
+ moderation_actions: 'SELECT * FROM moderation_actions WHERE id = ?',
+};
+
function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), log = console } = {}) {
function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), log = console } = {}) {
let timer = null;
let timer = null;
let busy = false;
let busy = false;
let lastError = null;
let lastError = null;
- function pending() { return db.get('SELECT COUNT(*) AS n FROM live_mirror_outbox')?.n || 0; }
+ async function pending() { return (await db.get('SELECT COUNT(*) AS n FROM live_mirror_outbox'))?.n || 0; }
- function buildChanges(rows) {
+ async function buildChanges(rows) {
// Newest state per row: several changes to one key collapse into the last op.
// Newest state per row: several changes to one key collapse into the last op.
const byKey = new Map();
const byKey = new Map();
for (const r of rows) byKey.set(`${r.tbl}|${r.pk}`, r);
for (const r of rows) byKey.set(`${r.tbl}|${r.pk}`, r);
@@ -44,9 +61,8 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lo
@@ -44,9 +61,8 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lo
let pk;
let pk;
try { pk = JSON.parse(r.pk); } catch { continue; }
try { pk = JSON.parse(r.pk); } catch { continue; }
if (r.op === 'delete') { changes.push({ table: r.tbl, op: 'delete', pk }); continue; }
if (r.op === 'delete') { changes.push({ table: r.tbl, op: 'delete', pk }); continue; }
- const where = cols.map((c) => `${c} = ?`).join(' AND ');
- const row = db.get(`SELECT * FROM ${r.tbl} WHERE ${where}`, cols.map((c) => pk[c]));
- changes.push(row ? { table: r.tbl, op: 'upsert', row } : { table: r.tbl, op: 'delete', pk });
+ const row = await db.get(ROW_SQL[r.tbl], cols.map((c) => pk[c]));
+ changes.push(row ? { table: r.tbl, op: 'upsert', row: r.tbl === 'moderation_actions' ? db.moderationRow(row) : r.tbl === 'chat_messages' ? db.chatMessageRow(row) : row } : { table: r.tbl, op: 'delete', pk });
}
}
return changes;
return changes;
}
}
@@ -58,9 +74,9 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lo
@@ -58,9 +74,9 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lo
let sent = 0;
let sent = 0;
try {
try {
for (;;) {
for (;;) {
- const rows = db.all('SELECT seq, tbl, op, pk FROM live_mirror_outbox ORDER BY seq LIMIT ?', [BATCH]);
+ const rows = await db.all('SELECT seq, tbl, op, pk FROM live_mirror_outbox ORDER BY seq LIMIT ?', [BATCH]);
if (!rows.length) break;
if (!rows.length) break;
- const changes = buildChanges(rows);
+ const changes = await buildChanges(rows);
// Requests stay under Live's body limit; a batch is acknowledged when all its parts are.
// Requests stay under Live's body limit; a batch is acknowledged when all its parts are.
const parts = [[]];
const parts = [[]];
let size = 0;
let size = 0;
@@ -91,17 +107,26 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lo
@@ -91,17 +107,26 @@ function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), lo
}
}
if (failed) break;
if (failed) break;
const maxSeq = rows[rows.length - 1].seq;
const maxSeq = rows[rows.length - 1].seq;
- db.run('DELETE FROM live_mirror_outbox WHERE seq <= ?', [maxSeq]);
+ await db.run('DELETE FROM live_mirror_outbox WHERE seq <= ?', [maxSeq]);
sent += changes.length;
sent += changes.length;
lastError = null;
lastError = null;
}
}
} finally { busy = false; }
} finally { busy = false; }
- if (lastError) log.warn(`[Mirror] Live mirror waiting (${pending()} pending): ${lastError}`);
- return { sent, pending: pending(), error: lastError };
+ if (lastError) log.warn(`[Mirror] Live mirror waiting (${await pending()} pending): ${lastError}`);
+ return { sent, pending: await pending(), error: lastError };
}
}
+ // The triggers record every change; with the mirror off (rehearsals, tests) nothing is kept, as when the capture
+ // was per connection and off: the queue is emptied on the same cadence instead of sent.
+ async function discard() { await db.run('DELETE FROM live_mirror_outbox'); }
+
function start() {
function start() {
- if (timer || !config.live.mirror) return;
+ if (timer) return;
+ if (!config.live.mirror) {
+ timer = setInterval(() => { discard().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);
+ if (timer.unref) timer.unref();
+ return;
+ }
timer = setInterval(() => { flush().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);
timer = setInterval(() => { flush().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);
if (timer.unref) timer.unref();
if (timer.unref) timer.unref();
}
}
@@ -83,7 +83,7 @@ class CallServer {
@@ -83,7 +83,7 @@ class CallServer {
});
});
}
}
- init(server) {
+ async init(server) {
this.wss = new WebSocket.Server({ noServer: true, maxPayload: 256 * 1024, perMessageDeflate: false });
this.wss = new WebSocket.Server({ noServer: true, maxPayload: 256 * 1024, perMessageDeflate: false });
if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
this._heartbeatInterval = setInterval(() => {
this._heartbeatInterval = setInterval(() => {
@@ -95,21 +95,21 @@ class CallServer {
@@ -95,21 +95,21 @@ class CallServer {
});
});
}, WS_HEARTBEAT_MS);
}, WS_HEARTBEAT_MS);
if (this._heartbeatInterval.unref) this._heartbeatInterval.unref();
if (this._heartbeatInterval.unref) this._heartbeatInterval.unref();
- this.wss.on('connection', (ws, req) => this._handleConnection(ws, req));
+ this.wss.on('connection', async (ws, req) => await this._handleConnection(ws, req));
// Cleanup user-created channels that have been empty for over an hour
// Cleanup user-created channels that have been empty for over an hour
if (this._inactivityTimer) clearInterval(this._inactivityTimer);
if (this._inactivityTimer) clearInterval(this._inactivityTimer);
- this._inactivityTimer = setInterval(() => this._cleanupInactiveChannels(), 5 * 60 * 1000);
+ this._inactivityTimer = setInterval(async () => await this._cleanupInactiveChannels(), 5 * 60 * 1000);
if (this._inactivityTimer.unref) this._inactivityTimer.unref();
if (this._inactivityTimer.unref) this._inactivityTimer.unref();
// Rooms are memory: calls the previous process left open are over.
// Rooms are memory: calls the previous process left open are over.
- try { lifecycle.recover(); } catch (err) { console.warn('[Calls] recover failed:', err.message); }
+ try { await lifecycle.recover(); } catch (err) { console.warn('[Calls] recover failed:', err.message); }
console.log(`[CallServer] Voice channels initialized (${config.calls.enabled ? 'serving' : 'off: CHAT_CALLS is not set'})`);
console.log(`[CallServer] Voice channels initialized (${config.calls.enabled ? 'serving' : 'off: CHAT_CALLS is not set'})`);
return this.wss;
return this.wss;
}
}
- _cleanupInactiveChannels() {
+ async _cleanupInactiveChannels() {
const ONE_HOUR = 60 * 60 * 1000;
const ONE_HOUR = 60 * 60 * 1000;
const now = Date.now();
const now = Date.now();
for (const [k, until] of this.kickCooldown) if (until <= now) this.kickCooldown.delete(k);
for (const [k, until] of this.kickCooldown) if (until <= now) this.kickCooldown.delete(k);
@@ -121,7 +121,7 @@ class CallServer {
@@ -121,7 +121,7 @@ class CallServer {
console.log(`[CallServer] Auto-deleting inactive channel "${ch.name}" (${channelId}) — empty for ${Math.round((now - ch.lastEmptiedAt) / 60000)}m`);
console.log(`[CallServer] Auto-deleting inactive channel "${ch.name}" (${channelId}) — empty for ${Math.round((now - ch.lastEmptiedAt) / 60000)}m`);
this.channels.delete(channelId);
this.channels.delete(channelId);
this.callBans.delete(channelId);
this.callBans.delete(channelId);
- lifecycle.channelClosed(channelId, 'inactive');
+ await lifecycle.channelClosed(channelId, 'inactive');
}
}
}
}
}
}
@@ -135,11 +135,11 @@ class CallServer {
@@ -135,11 +135,11 @@ class CallServer {
/* ── Channel management ──────────────────────────────────── */
/* ── Channel management ──────────────────────────────────── */
/** Channels a viewer may see: a private call only for its creator, its invitees and staff. */
/** Channels a viewer may see: a private call only for its creator, its invitees and staff. */
- listChannels(viewer = null) {
+ async listChannels(viewer = null) {
const result = [];
const result = [];
for (const [id, ch] of this.channels) {
for (const [id, ch] of this.channels) {
if (ch.roomSlug) continue; // call rooms are listed as rooms (openvibe.chat), not in the voice sidebar
if (ch.roomSlug) continue; // call rooms are listed as rooms (openvibe.chat), not in the voice sidebar
- if (ch.private && !this._canSeePrivate(ch, viewer)) continue;
+ if (ch.private && !await this._canSeePrivate(ch, viewer)) continue;
const room = this.rooms.get(id);
const room = this.rooms.get(id);
const participants = [];
const participants = [];
if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }
if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }
@@ -148,8 +148,8 @@ class CallServer {
@@ -148,8 +148,8 @@ class CallServer {
return result;
return result;
}
}
_publicChannel(ch) { const { invited, ...rest } = ch; return rest; }
_publicChannel(ch) { const { invited, ...rest } = ch; return rest; }
- _canSeePrivate(ch, viewer) {
- if (ch.roomSlug) { const room = this._roomOf(ch.id); return !!room && rooms.access(room, viewer).read; }
+ async _canSeePrivate(ch, viewer) {
+ if (ch.roomSlug) { const room = await this._roomOf(ch.id); return !!room && (await rooms.access(room, viewer)).read; }
if (!ch.private) return true;
if (!ch.private) return true;
if (!viewer) return false;
if (!viewer) return false;
if (permissions.can(viewer, 'staff.moderation.calls')) return true;
if (permissions.can(viewer, 'staff.moderation.calls')) return true;
@@ -170,24 +170,24 @@ class CallServer {
@@ -170,24 +170,24 @@ class CallServer {
/** Debounced "the list changed" for every chat socket (the sidebar used to poll every 4 s). */
/** Debounced "the list changed" for every chat socket (the sidebar used to poll every 4 s). */
_notifyChannelsChanged() {
_notifyChannelsChanged() {
if (this._notifyTimer) return;
if (this._notifyTimer) return;
- this._notifyTimer = setTimeout(() => {
+ this._notifyTimer = setTimeout(async () => {
this._notifyTimer = null;
this._notifyTimer = null;
- try { chatServer.broadcastAll({ type: 'voice-channels', channels: this.listChannels(null) }); } catch { /* */ }
+ try { await chatServer.broadcastAll({ type: 'voice-channels', channels: await this.listChannels(null) }); } catch { /* */ }
}, 400);
}, 400);
if (this._notifyTimer.unref) this._notifyTimer.unref();
if (this._notifyTimer.unref) this._notifyTimer.unref();
}
}
/** The call room behind a `room-<slug>` channel id, or null. */
/** The call room behind a `room-<slug>` channel id, or null. */
- _roomOf(channelId) {
+ async _roomOf(channelId) {
const m = ROOM_CHANNEL.exec(String(channelId || ''));
const m = ROOM_CHANNEL.exec(String(channelId || ''));
if (!m) return null;
if (!m) return null;
- const room = rooms.bySlug(m[1]);
+ const room = await rooms.bySlug(m[1]);
return room && room.kind === 'call' ? room : null;
return room && room.kind === 'call' ? room : null;
}
}
/** The channel of a call room, made on first use and named after the room. → channel | null */
/** The channel of a call room, made on first use and named after the room. → channel | null */
- ensureRoomChannel(channelId) {
- const room = this._roomOf(channelId);
+ async ensureRoomChannel(channelId) {
+ const room = await this._roomOf(channelId);
if (!room) return null;
if (!room) return null;
let ch = this.channels.get(channelId);
let ch = this.channels.get(channelId);
if (!ch) {
if (!ch) {
@@ -200,9 +200,9 @@ class CallServer {
@@ -200,9 +200,9 @@ class CallServer {
return ch;
return ch;
}
}
- getChannel(channelId, viewer = null) {
+ async getChannel(channelId, viewer = null) {
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
- if (!ch || !this._canSeePrivate(ch, viewer)) return null;
+ if (!ch || !await this._canSeePrivate(ch, viewer)) return null;
const room = this.rooms.get(channelId);
const room = this.rooms.get(channelId);
const participants = [];
const participants = [];
if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }
if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }
@@ -231,11 +231,11 @@ class CallServer {
@@ -231,11 +231,11 @@ class CallServer {
}
}
/** Live's go-live / call-mode hook. `stream` is the stream row when the caller has it (its title). */
/** Live's go-live / call-mode hook. `stream` is the stream row when the caller has it (its title). */
- createStreamChannel(streamId, mode, streamerId, stream = null) {
+ async createStreamChannel(streamId, mode, streamerId, stream = null) {
const id = `stream-${streamId}`;
const id = `stream-${streamId}`;
const existing = this.channels.get(id);
const existing = this.channels.get(id);
- if (existing) { const old = existing.mode; existing.mode = mode; if (old !== mode) this.endCall(id); return existing; }
- if (!stream) stream = ctx.getStreamById(streamId);
+ if (existing) { const old = existing.mode; existing.mode = mode; if (old !== mode) await this.endCall(id); return existing; }
+ if (!stream) stream = await ctx.getStreamById(streamId);
const ch = {
const ch = {
id, name: stream ? (stream.title || `Stream ${streamId}`) : `Stream ${streamId}`,
id, name: stream ? (stream.title || `Stream ${streamId}`) : `Stream ${streamId}`,
mode: MODES.includes(mode) ? mode : 'mic',
mode: MODES.includes(mode) ? mode : 'mic',
@@ -245,27 +245,27 @@ class CallServer {
@@ -245,27 +245,27 @@ class CallServer {
return ch;
return ch;
}
}
- removeStreamChannel(streamId) {
+ async removeStreamChannel(streamId) {
const id = `stream-${streamId}`;
const id = `stream-${streamId}`;
const had = this.channels.has(id);
const had = this.channels.has(id);
- this.endCall(id, 'stream_ended');
+ await this.endCall(id, 'stream_ended');
this.channels.delete(id);
this.channels.delete(id);
this.callBans.delete(id);
this.callBans.delete(id);
- lifecycle.channelClosed(id, 'stream_ended');
+ await lifecycle.channelClosed(id, 'stream_ended');
return had;
return had;
}
}
/** `user` is the requester (Live passed an id and looked the account up; Chat's routes have it). */
/** `user` is the requester (Live passed an id and looked the account up; Chat's routes have it). */
- deleteChannel(channelId, user) {
+ async deleteChannel(channelId, user) {
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
if (!ch || ch.permanent || !user) return false;
if (!ch || ch.permanent || !user) return false;
if (ch.createdBy !== user.id) {
if (ch.createdBy !== user.id) {
if (!permissions.can(user, 'staff.moderation.calls')) return false;
if (!permissions.can(user, 'staff.moderation.calls')) return false;
}
}
- this.endCall(channelId, 'closed');
+ await this.endCall(channelId, 'closed');
this.channels.delete(channelId);
this.channels.delete(channelId);
this.callBans.delete(channelId);
this.callBans.delete(channelId);
- lifecycle.channelClosed(channelId, 'closed');
+ await lifecycle.channelClosed(channelId, 'closed');
this._notifyChannelsChanged();
this._notifyChannelsChanged();
return true;
return true;
}
}
@@ -295,7 +295,7 @@ class CallServer {
@@ -295,7 +295,7 @@ class CallServer {
};
};
}
}
- _handleConnection(ws, req) {
+ async _handleConnection(ws, req) {
const url = new URL(req.url, 'http://localhost');
const url = new URL(req.url, 'http://localhost');
const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');
const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');
const token = extractWsToken(req);
const token = extractWsToken(req);
@@ -311,7 +311,7 @@ class CallServer {
@@ -311,7 +311,7 @@ class CallServer {
// Legacy: bare number → stream-<N>
// Legacy: bare number → stream-<N>
const resolvedId = /^\d+$/.test(channelId) ? `stream-${channelId}` : channelId;
const resolvedId = /^\d+$/.test(channelId) ? `stream-${channelId}` : channelId;
- if (!this.channels.get(resolvedId)) this.ensureRoomChannel(resolvedId);
+ if (!this.channels.get(resolvedId)) await this.ensureRoomChannel(resolvedId);
if (!this.channels.get(resolvedId)) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
if (!this.channels.get(resolvedId)) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
// Sign-in, the anon number and the stream are (cached) calls to Live now. Messages that arrive
// Sign-in, the anon number and the stream are (cached) calls to Live now. Messages that arrive
@@ -326,7 +326,7 @@ class CallServer {
@@ -326,7 +326,7 @@ class CallServer {
(async () => {
(async () => {
const first = this.channels.get(resolvedId);
const first = this.channels.get(resolvedId);
let stream = null;
let stream = null;
- if (first && first.streamId) stream = ctx.getStreamById(first.streamId) || await ctx.ensureStream(first.streamId).catch(() => null);
+ if (first && first.streamId) stream = await ctx.getStreamById(first.streamId) || await ctx.ensureStream(first.streamId).catch(() => null);
const user = await authenticateWs(token).catch(() => null);
const user = await authenticateWs(token).catch(() => null);
if (!user) await chatServer._resolveUnifiedAnonNum(chatServer.normalizeIp(ip)).catch(() => {});
if (!user) await chatServer._resolveUnifiedAnonNum(chatServer.normalizeIp(ip)).catch(() => {});
await ctx.warm({ user, streamId: first && first.streamId ? first.streamId : null, ip }).catch(() => {});
await ctx.warm({ user, streamId: first && first.streamId ? first.streamId : null, ip }).catch(() => {});
@@ -334,7 +334,7 @@ class CallServer {
@@ -334,7 +334,7 @@ class CallServer {
ws.off('close', onEarlyClose);
ws.off('close', onEarlyClose);
ws.off('error', onEarlyClose);
ws.off('error', onEarlyClose);
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
- this._admit(ws, { resolvedId, ip, user, stream, early });
+ await this._admit(ws, { resolvedId, ip, user, stream, early });
})().catch((err) => {
})().catch((err) => {
console.warn('[Call] connection setup failed:', err.message);
console.warn('[Call] connection setup failed:', err.message);
try { ws.close(1011, 'setup failed'); } catch {}
try { ws.close(1011, 'setup failed'); } catch {}
@@ -342,24 +342,24 @@ class CallServer {
@@ -342,24 +342,24 @@ class CallServer {
}
}
/** The rest of Live's _handleConnection, once the socket's identity is known. */
/** The rest of Live's _handleConnection, once the socket's identity is known. */
- _admit(ws, { resolvedId, ip, user, stream, early }) {
+ async _admit(ws, { resolvedId, ip, user, stream, early }) {
// The channel may have gone while the socket signed in.
// The channel may have gone while the socket signed in.
const channel = this.channels.get(resolvedId);
const channel = this.channels.get(resolvedId);
if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
if (!channel) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
// If stream-linked, verify stream is live
// If stream-linked, verify stream is live
if (channel.streamId) {
if (channel.streamId) {
- if (!stream || Number(stream.id) !== Number(channel.streamId)) stream = ctx.getStreamById(channel.streamId);
+ if (!stream || Number(stream.id) !== Number(channel.streamId)) stream = await ctx.getStreamById(channel.streamId);
if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }
if (!stream || !stream.is_live) { ws.send(JSON.stringify({ type: 'error', message: 'Stream not live' })); ws.close(); return; }
}
}
- if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
+ if (channel.private && !await this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
// A call room: the room's roles decide who is in and who talks.
// A call room: the room's roles decide who is in and who talks.
let roomAccess = null;
let roomAccess = null;
if (channel.roomSlug) {
if (channel.roomSlug) {
- const room = this._roomOf(resolvedId);
- roomAccess = room ? rooms.access(room, user) : null;
+ const room = await this._roomOf(resolvedId);
+ roomAccess = room ? await rooms.access(room, user) : null;
if (!roomAccess || !roomAccess.read) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
if (!roomAccess || !roomAccess.read) { ws.send(JSON.stringify({ type: 'error', message: 'Voice channel not found' })); ws.close(); return; }
if (!roomAccess.join) { ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); ws.close(); return; }
if (!roomAccess.join) { ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); ws.close(); return; }
}
}
@@ -371,8 +371,8 @@ class CallServer {
@@ -371,8 +371,8 @@ class CallServer {
const cooled = this.kickCooldown.get(`${resolvedId}:${identity}`);
const cooled = this.kickCooldown.get(`${resolvedId}:${identity}`);
if (cooled && cooled > Date.now()) { ws.send(JSON.stringify({ type: 'error', message: 'You were removed from this channel; try again in a minute' })); ws.close(); return; }
if (cooled && cooled > Date.now()) { ws.send(JSON.stringify({ type: 'error', message: 'You were removed from this channel; try again in a minute' })); ws.close(); return; }
const isChannelCreator = user && channel.createdBy === user.id;
const isChannelCreator = user && channel.createdBy === user.id;
- const isStreamer = channel.streamId ? (user && ctx.getStreamById(channel.streamId)?.user_id === user.id) : false;
- const canModerate = this._canModerate(user, resolvedId);
+ const isStreamer = channel.streamId ? (user && (await ctx.getStreamById(channel.streamId))?.user_id === user.id) : false;
+ const canModerate = await this._canModerate(user, resolvedId);
const bans = this.callBans.get(resolvedId);
const bans = this.callBans.get(resolvedId);
if (bans) {
if (bans) {
@@ -392,7 +392,7 @@ class CallServer {
@@ -392,7 +392,7 @@ class CallServer {
for (const [pid, info] of room) {
for (const [pid, info] of room) {
if (info.user && info.user.id === user.id) {
if (info.user && info.user.id === user.id) {
try { info.ws.send(JSON.stringify({ type: 'replaced' })); } catch { /* */ }
try { info.ws.send(JSON.stringify({ type: 'replaced' })); } catch { /* */ }
- this._handleDisconnect(info.ws, resolvedId, pid);
+ await this._handleDisconnect(info.ws, resolvedId, pid);
try { info.ws.close(); } catch { /* */ }
try { info.ws.close(); } catch { /* */ }
}
}
}
}
@@ -419,8 +419,8 @@ class CallServer {
@@ -419,8 +419,8 @@ class CallServer {
}
}
// The lifecycle: the channel's session is active; an invitee joining answers the ring.
// The lifecycle: the channel's session is active; an invitee joining answers the ring.
try {
try {
- if (room.size === 1) lifecycle.sessionStarted({ channelId: resolvedId, streamId: channel.streamId || null, createdBy: channel.createdBy || null });
- if (user) lifecycle.answeredByJoin(resolvedId, user.id);
+ if (room.size === 1) await lifecycle.sessionStarted({ channelId: resolvedId, streamId: channel.streamId || null, createdBy: channel.createdBy || null });
+ if (user) await lifecycle.answeredByJoin(resolvedId, user.id);
} catch (err) { console.warn('[Calls] lifecycle:', err.message); }
} catch (err) { console.warn('[Calls] lifecycle:', err.message); }
const participants = [];
const participants = [];
@@ -455,22 +455,22 @@ class CallServer {
@@ -455,22 +455,22 @@ class CallServer {
const now = Date.now();
const now = Date.now();
if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }
if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }
if (++clientInfo._msgCount > 200) return;
if (++clientInfo._msgCount > 200) return;
- queue = queue.then(() => this._handleMessage(ws, JSON.parse(data), resolvedId, peerId))
+ queue = queue.then(async () => await this._handleMessage(ws, JSON.parse(data), resolvedId, peerId))
.catch((err) => { console.warn('[Call] Message error for peer', peerId, ':', err.message); });
.catch((err) => { console.warn('[Call] Message error for peer', peerId, ':', err.message); });
};
};
ws.on('message', onMessage);
ws.on('message', onMessage);
- ws.on('close', () => this._handleDisconnect(ws, resolvedId, peerId));
- ws.on('error', () => this._handleDisconnect(ws, resolvedId, peerId));
+ ws.on('close', async () => await this._handleDisconnect(ws, resolvedId, peerId));
+ ws.on('error', async () => await this._handleDisconnect(ws, resolvedId, peerId));
for (const data of early) onMessage(data);
for (const data of early) onMessage(data);
}
}
- _canModerate(user, channelId) {
+ async _canModerate(user, channelId) {
if (!user) return false;
if (!user) return false;
if (permissions.can(user, 'staff.moderation.calls')) return true;
if (permissions.can(user, 'staff.moderation.calls')) return true;
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
- if (ch?.roomSlug) { const room = this._roomOf(channelId); return !!room && rooms.access(room, user).moderate; }
+ if (ch?.roomSlug) { const room = await this._roomOf(channelId); return !!room && (await rooms.access(room, user)).moderate; }
if (ch?.createdBy === user.id) return true;
if (ch?.createdBy === user.id) return true;
- if (ch?.streamId) return permissions.canModerateCall(user, ch.streamId);
+ if (ch?.streamId) return await permissions.canModerateCall(user, ch.streamId);
return false;
return false;
}
}
@@ -532,20 +532,20 @@ class CallServer {
@@ -532,20 +532,20 @@ class CallServer {
}
}
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
if (ch?.roomSlug) {
if (ch?.roomSlug) {
- const callRoom = this._roomOf(channelId);
- const a = callRoom ? rooms.access(callRoom, user) : null;
+ const callRoom = await this._roomOf(channelId);
+ const a = callRoom ? await rooms.access(callRoom, user) : null;
if (!a || !a.read || !a.join) { if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); c.ws.close(); } break; }
if (!a || !a.read || !a.join) { if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'You cannot join this call' })); c.ws.close(); } break; }
}
}
c.user = user; c.anonId = user ? null : chatServer.getAnonIdForConnection(c.ip, channelId);
c.user = user; c.anonId = user ? null : chatServer.getAnonIdForConnection(c.ip, channelId);
c.isChannelCreator = !!(user && ch?.createdBy === user.id);
c.isChannelCreator = !!(user && ch?.createdBy === user.id);
- c.isStreamer = ch?.streamId ? !!(user && ctx.getStreamById(ch.streamId)?.user_id === user.id) : false;
- if (user) { try { lifecycle.answeredByJoin(channelId, user.id); } catch { /* */ } }
- if (ch?.roomSlug) { this._applyRoomAccessTo(channelId, peerId, c); if (room.get(peerId) !== c) break; }
+ c.isStreamer = ch?.streamId ? !!(user && (await ctx.getStreamById(ch.streamId))?.user_id === user.id) : false;
+ if (user) { try { await lifecycle.answeredByJoin(channelId, user.id); } catch { /* */ } }
+ if (ch?.roomSlug) { await this._applyRoomAccessTo(channelId, peerId, c); if (room.get(peerId) !== c) break; }
const pInfo = this._buildParticipantInfo(peerId, c);
const pInfo = this._buildParticipantInfo(peerId, c);
if (c.ws.readyState === WebSocket.OPEN) c.ws.send(JSON.stringify({
if (c.ws.readyState === WebSocket.OPEN) c.ws.send(JSON.stringify({
type: 'self-updated',
type: 'self-updated',
isStreamer: c.isStreamer || c.isChannelCreator,
isStreamer: c.isStreamer || c.isChannelCreator,
- canModerate: this._canModerate(user, channelId),
+ canModerate: await this._canModerate(user, channelId),
participant: pInfo,
participant: pInfo,
}));
}));
const u = JSON.stringify({ type: 'peer-updated', ...pInfo });
const u = JSON.stringify({ type: 'peer-updated', ...pInfo });
@@ -553,7 +553,7 @@ class CallServer {
@@ -553,7 +553,7 @@ class CallServer {
break;
break;
}
}
case 'force-mute': {
case 'force-mute': {
- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
+ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
if (target.listenOnly && !msg.forceMuted) break; // make them a speaker in the room instead
if (target.listenOnly && !msg.forceMuted) break; // make them a speaker in the room instead
target.forceMuted = !!msg.forceMuted;
target.forceMuted = !!msg.forceMuted;
@@ -563,7 +563,7 @@ class CallServer {
@@ -563,7 +563,7 @@ class CallServer {
break;
break;
}
}
case 'force-camera-off': {
case 'force-camera-off': {
- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
+ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
if (target.listenOnly && !msg.forceCameraOff) break;
if (target.listenOnly && !msg.forceCameraOff) break;
target.forceCameraOff = !!msg.forceCameraOff;
target.forceCameraOff = !!msg.forceCameraOff;
@@ -573,7 +573,7 @@ class CallServer {
@@ -573,7 +573,7 @@ class CallServer {
break;
break;
}
}
case 'kick': {
case 'kick': {
- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
+ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
if (target.identity) this.kickCooldown.set(`${channelId}:${target.identity}`, Date.now() + KICK_COOLDOWN_MS);
if (target.identity) this.kickCooldown.set(`${channelId}:${target.identity}`, Date.now() + KICK_COOLDOWN_MS);
if (target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'kicked' })); target.ws.close(); }
if (target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'kicked' })); target.ws.close(); }
@@ -584,7 +584,7 @@ class CallServer {
@@ -584,7 +584,7 @@ class CallServer {
break;
break;
}
}
case 'ban': {
case 'ban': {
- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
+ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
if (!this.callBans.has(channelId)) this.callBans.set(channelId, new Set());
if (!this.callBans.has(channelId)) this.callBans.set(channelId, new Set());
const banSet = this.callBans.get(channelId);
const banSet = this.callBans.get(channelId);
@@ -592,9 +592,9 @@ class CallServer {
@@ -592,9 +592,9 @@ class CallServer {
banSet.add(target.user.id); // backward compatibility with existing entries
banSet.add(target.user.id); // backward compatibility with existing entries
banSet.add(`u:${target.user.id}`);
banSet.add(`u:${target.user.id}`);
// In a call room the ban is the room's block, so it outlives this process.
// In a call room the ban is the room's block, so it outlives this process.
- const callRoom = this._roomOf(channelId);
+ const callRoom = await this._roomOf(channelId);
if (callRoom && sender.user) {
if (callRoom && sender.user) {
- try { rooms.setRole(callRoom, sender.user, target.user.id, 'blocked'); chatServer.refreshRoomAccess(callRoom); } catch (err) { console.warn('[Call] room block:', err.message); }
+ try { await rooms.setRole(callRoom, sender.user, target.user.id, 'blocked'); await chatServer.refreshRoomAccess(callRoom); } catch (err) { console.warn('[Call] room block:', err.message); }
}
}
} else {
} else {
if (target.anonId) banSet.add(`a:${target.anonId}`);
if (target.anonId) banSet.add(`a:${target.anonId}`);
@@ -608,7 +608,7 @@ class CallServer {
@@ -608,7 +608,7 @@ class CallServer {
break;
break;
}
}
case 'unban': {
case 'unban': {
- const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
+ const sender = room.get(peerId); if (!sender || !await this._canModerate(sender.user, channelId)) break;
const uid = parseInt(msg.userId);
const uid = parseInt(msg.userId);
if (uid && this.callBans.has(channelId)) {
if (uid && this.callBans.has(channelId)) {
this.callBans.get(channelId).delete(uid);
this.callBans.get(channelId).delete(uid);
@@ -621,15 +621,18 @@ class CallServer {
@@ -621,15 +621,18 @@ class CallServer {
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
const canEnd = (ch?.createdBy && c.user?.id === ch.createdBy) || c.isStreamer || permissions.can(c.user, 'staff.moderation.calls');
const canEnd = (ch?.createdBy && c.user?.id === ch.createdBy) || c.isStreamer || permissions.can(c.user, 'staff.moderation.calls');
if (canEnd) {
if (canEnd) {
- this.endCall(channelId, 'ended');
- if (ch && !ch.permanent) { this.channels.delete(channelId); lifecycle.channelClosed(channelId, 'ended'); }
+ // The channel leaves memory before the database is written, as when the write was synchronous.
+ const closing = ch && !ch.permanent;
+ if (closing) this.channels.delete(channelId);
+ await this.endCall(channelId, 'ended');
+ if (closing) await lifecycle.channelClosed(channelId, 'ended');
}
}
break;
break;
}
}
}
}
}
}
- _handleDisconnect(ws, channelId, peerId) {
+ async _handleDisconnect(ws, channelId, peerId) {
if (!this.clients.has(ws)) return; // already handled (error+close fire back-to-back)
if (!this.clients.has(ws)) return; // already handled (error+close fire back-to-back)
this.clients.delete(ws);
this.clients.delete(ws);
const room = this.rooms.get(channelId);
const room = this.rooms.get(channelId);
@@ -649,7 +652,7 @@ class CallServer {
@@ -649,7 +652,7 @@ class CallServer {
if (ch && !ch.permanent && !ch.streamId) {
if (ch && !ch.permanent && !ch.streamId) {
ch.lastEmptiedAt = Date.now();
ch.lastEmptiedAt = Date.now();
}
}
- try { lifecycle.channelEmptied(channelId, 'empty'); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }
+ try { await lifecycle.channelEmptied(channelId, 'empty'); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }
}
}
}
}
@@ -676,7 +679,7 @@ class CallServer {
@@ -676,7 +679,7 @@ class CallServer {
* what they may do now. A new speaker may talk, a demoted one is muted, someone who may no longer
* what they may do now. A new speaker may talk, a demoted one is muted, someone who may no longer
* read the room (blocked, removed from a private room) is dropped. → peers changed
* read the room (blocked, removed from a private room) is dropped. → peers changed
*/
*/
- applyRoomAccess(room) {
+ async applyRoomAccess(room) {
if (!room || room.kind !== 'call') return 0;
if (!room || room.kind !== 'call') return 0;
const channelId = `room-${room.slug}`;
const channelId = `room-${room.slug}`;
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
@@ -684,18 +687,18 @@ class CallServer {
@@ -684,18 +687,18 @@ class CallServer {
const r = this.rooms.get(channelId);
const r = this.rooms.get(channelId);
if (!r) return 0;
if (!r) return 0;
let changed = 0;
let changed = 0;
- for (const [pid, info] of [...r]) if (this._applyRoomAccessTo(channelId, pid, info, room)) changed++;
+ for (const [pid, info] of [...r]) if (await this._applyRoomAccessTo(channelId, pid, info, room)) changed++;
return changed;
return changed;
}
}
/** One peer of a call room against the room's current roles. → whether anything changed */
/** One peer of a call room against the room's current roles. → whether anything changed */
- _applyRoomAccessTo(channelId, peerId, info, room = null) {
- const callRoom = room || this._roomOf(channelId);
- const a = callRoom ? rooms.access(callRoom, info.user || null) : null;
+ async _applyRoomAccessTo(channelId, peerId, info, room = null) {
+ const callRoom = room || await this._roomOf(channelId);
+ const a = callRoom ? await rooms.access(callRoom, info.user || null) : null;
const send = (o) => { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(JSON.stringify(o)); };
const send = (o) => { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(JSON.stringify(o)); };
if (!a || !a.read || !a.join) {
if (!a || !a.read || !a.join) {
send({ type: 'error', message: 'You can no longer join this call' });
send({ type: 'error', message: 'You can no longer join this call' });
- this._handleDisconnect(info.ws, channelId, peerId);
+ await this._handleDisconnect(info.ws, channelId, peerId);
try { info.ws.close(); } catch { /* */ }
try { info.ws.close(); } catch { /* */ }
return true;
return true;
}
}
@@ -707,7 +710,7 @@ class CallServer {
@@ -707,7 +710,7 @@ class CallServer {
info.forceMuted = listenOnly;
info.forceMuted = listenOnly;
info.forceCameraOff = listenOnly;
info.forceCameraOff = listenOnly;
if (listenOnly) { info.muted = true; info.cameraOff = true; info.speaking = false; }
if (listenOnly) { info.muted = true; info.cameraOff = true; info.speaking = false; }
- send({ type: 'room-role', room: callRoom.slug, role: a.role, canTalk: a.talk, canModerate: this._canModerate(info.user, channelId) });
+ send({ type: 'room-role', room: callRoom.slug, role: a.role, canTalk: a.talk, canModerate: await this._canModerate(info.user, channelId) });
send({ type: 'force-muted', forceMuted: info.forceMuted });
send({ type: 'force-muted', forceMuted: info.forceMuted });
send({ type: 'force-camera-off', forceCameraOff: info.forceCameraOff });
send({ type: 'force-camera-off', forceCameraOff: info.forceCameraOff });
const r = this.rooms.get(channelId);
const r = this.rooms.get(channelId);
@@ -725,23 +728,24 @@ class CallServer {
@@ -725,23 +728,24 @@ class CallServer {
return true;
return true;
}
}
- endCall(channelId, reason = 'ended') {
+ async endCall(channelId, reason = 'ended') {
const r = this.rooms.get(channelId); if (!r) return;
const r = this.rooms.get(channelId); if (!r) return;
const entries = [...r.values()];
const entries = [...r.values()];
this.rooms.delete(channelId);
this.rooms.delete(channelId);
+ for (const info of entries) this.clients.delete(info.ws);
+ // The session row ends before anyone hears call-ended (the order it had when the write was synchronous).
+ try { await lifecycle.channelEmptied(channelId, reason); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }
const m = JSON.stringify({ type: 'call-ended' });
const m = JSON.stringify({ type: 'call-ended' });
for (const info of entries) {
for (const info of entries) {
- this.clients.delete(info.ws);
if (info.ws.readyState === WebSocket.OPEN) { info.ws.send(m); info.ws.close(); }
if (info.ws.readyState === WebSocket.OPEN) { info.ws.send(m); info.ws.close(); }
}
}
- try { lifecycle.channelEmptied(channelId, reason); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }
}
}
/** Legacy compat: end call by stream ID */
/** Legacy compat: end call by stream ID */
- endStreamCall(streamId) { this.endCall(`stream-${streamId}`); }
+ async endStreamCall(streamId) { await this.endCall(`stream-${streamId}`); }
- close() {
- for (const [cid] of this.rooms) this.endCall(cid, 'restart');
+ async close() {
+ for (const [cid] of this.rooms) await this.endCall(cid, 'restart');
if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); this._heartbeatInterval = null; }
if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); this._heartbeatInterval = null; }
if (this._inactivityTimer) { clearInterval(this._inactivityTimer); this._inactivityTimer = null; }
if (this._inactivityTimer) { clearInterval(this._inactivityTimer); this._inactivityTimer = null; }
if (this._notifyTimer) { clearTimeout(this._notifyTimer); this._notifyTimer = null; }
if (this._notifyTimer) { clearTimeout(this._notifyTimer); this._notifyTimer = null; }
@@ -44,8 +44,8 @@ function createInternalRouter() {
@@ -44,8 +44,8 @@ function createInternalRouter() {
try {
try {
// Bounded: a slow Live must not hold Live's own go-live; the projection answers meanwhile.
// Bounded: a slow Live must not hold Live's own go-live; the projection answers meanwhile.
const stream = await Promise.race([ctx.refreshStream(streamId), new Promise((r) => setTimeout(() => r(null), STREAM_LOOKUP_MS).unref?.())])
const stream = await Promise.race([ctx.refreshStream(streamId), new Promise((r) => setTimeout(() => r(null), STREAM_LOOKUP_MS).unref?.())])
- || ctx.getStreamById(streamId);
- const ch = callServer.createStreamChannel(streamId, mode, userId, stream);
+ || await ctx.getStreamById(streamId);
+ const ch = await callServer.createStreamChannel(streamId, mode, userId, stream);
res.json({ ok: true, channel: callServer._publicChannel(ch) });
res.json({ ok: true, channel: callServer._publicChannel(ch) });
} catch (err) {
} catch (err) {
console.error('[Calls] stream-channel:', err.message);
console.error('[Calls] stream-channel:', err.message);
@@ -53,11 +53,11 @@ function createInternalRouter() {
@@ -53,11 +53,11 @@ function createInternalRouter() {
}
}
});
});
- router.delete('/stream-channel/:streamId', (req, res) => {
+ router.delete('/stream-channel/:streamId', async (req, res) => {
const streamId = Number(req.params.streamId);
const streamId = Number(req.params.streamId);
if (!Number.isInteger(streamId) || streamId <= 0) return res.status(400).json({ error: 'stream id required' });
if (!Number.isInteger(streamId) || streamId <= 0) return res.status(400).json({ error: 'stream id required' });
try {
try {
- res.json({ ok: true, removed: callServer.removeStreamChannel(streamId) });
+ res.json({ ok: true, removed: await callServer.removeStreamChannel(streamId) });
} catch (err) {
} catch (err) {
console.error('[Calls] stream-channel:', err.message);
console.error('[Calls] stream-channel:', err.message);
res.status(500).json({ error: 'Failed to remove the stream voice channel' });
res.status(500).json({ error: 'Failed to remove the stream voice channel' });
@@ -53,7 +53,7 @@ function canTransition(from, to) {
@@ -53,7 +53,7 @@ function canTransition(from, to) {
return !!(TRANSITIONS[from] && TRANSITIONS[from].includes(to));
return !!(TRANSITIONS[from] && TRANSITIONS[from].includes(to));
}
}
-function get(id) { return db.get('SELECT * FROM calls WHERE id = ?', [id]) || null; }
+async function get(id) { return await db.get('SELECT * FROM calls WHERE id = ?', [id]) || null; }
function clearTimer(id) {
function clearTimer(id) {
const t = _timers.get(id);
const t = _timers.get(id);
@@ -61,8 +61,8 @@ function clearTimer(id) {
@@ -61,8 +61,8 @@ function clearTimer(id) {
}
}
/** Move a call to `to` when its state allows it. Returns the updated row, or null (nothing written). */
/** Move a call to `to` when its state allows it. Returns the updated row, or null (nothing written). */
-function transition(id, to, { reason = null, now = Date.now() } = {}) {
- const row = get(id);
+async function transition(id, to, { reason = null, now = Date.now() } = {}) {
+ const row = await get(id);
if (!row || !canTransition(row.state, to)) return null;
if (!row || !canTransition(row.state, to)) return null;
const sets = ['state = ?'];
const sets = ['state = ?'];
const params = [to];
const params = [to];
@@ -72,46 +72,46 @@ function transition(id, to, { reason = null, now = Date.now() } = {}) {
@@ -72,46 +72,46 @@ function transition(id, to, { reason = null, now = Date.now() } = {}) {
if (row.kind === 'direct') { sets.push('answered_at = COALESCE(answered_at, ?)'); params.push(now); }
if (row.kind === 'direct') { sets.push('answered_at = COALESCE(answered_at, ?)'); params.push(now); }
}
}
if (!OPEN.includes(to)) { sets.push('ended_at = ?', 'end_reason = ?'); params.push(now, reason); }
if (!OPEN.includes(to)) { sets.push('ended_at = ?', 'end_reason = ?'); params.push(now, reason); }
- const r = db.run(`UPDATE calls SET ${sets.join(', ')} WHERE id = ? AND state = ?`, [...params, id, row.state]);
+ const r = await db.run(`UPDATE calls SET ${sets.join(', ')} WHERE id = ? AND state = ?`, [...params, id, row.state]);
if (r.changes !== 1) return null;
if (r.changes !== 1) return null;
if (to !== 'ringing') clearTimer(id);
if (to !== 'ringing') clearTimer(id);
- return get(id);
+ return await get(id);
}
}
-function insert({ kind, state, channelId, streamId = null, createdBy = null, targetId = null, now = Date.now() }) {
+async function insert({ kind, state, channelId, streamId = null, createdBy = null, targetId = null, now = Date.now() }) {
if (!KINDS.includes(kind) || !STATES.includes(state)) throw new Error(`bad call ${kind}/${state}`);
if (!KINDS.includes(kind) || !STATES.includes(state)) throw new Error(`bad call ${kind}/${state}`);
- const r = db.run(`INSERT INTO calls (kind, state, channel_id, stream_id, created_by, created_by_subject, target_user_id, target_subject, created_at, started_at)
+ const r = await db.run(`INSERT INTO calls (kind, state, channel_id, stream_id, created_by, created_by_subject, target_user_id, target_subject, created_at, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [kind, state, String(channelId), streamId || null, createdBy || null, db.subjectFor(createdBy), targetId || null, db.subjectFor(targetId), now, state === 'active' ? now : null]);
- return get(r.lastInsertRowid);
+ [kind, state, String(channelId), streamId || null, createdBy || null, await db.subjectFor(createdBy), targetId || null, await db.subjectFor(targetId), now, state === 'active' ? now : null]);
+ return await get(r.lastInsertRowid);
}
}
// ── Direct calls ────────────────────────────────────────────────────────────────────────────────
// ── Direct calls ────────────────────────────────────────────────────────────────────────────────
/** The caller's open ring to this person on this channel, if any (calling again re-rings it). */
/** The caller's open ring to this person on this channel, if any (calling again re-rings it). */
-function openDirectFor(callerId, targetId, channelId) {
- return db.get(`SELECT * FROM calls WHERE kind = 'direct' AND created_by = ? AND target_user_id = ? AND channel_id = ?
+async function openDirectFor(callerId, targetId, channelId) {
+ return await db.get(`SELECT * FROM calls WHERE kind = 'direct' AND created_by = ? AND target_user_id = ? AND channel_id = ?
AND state IN ('pending', 'ringing') ORDER BY id DESC LIMIT 1`, [callerId, targetId, String(channelId)]) || null;
AND state IN ('pending', 'ringing') ORDER BY id DESC LIMIT 1`, [callerId, targetId, String(channelId)]) || null;
}
}
/** A ring from callerId to targetId in channelId: the open one again, or a new pending row. */
/** A ring from callerId to targetId in channelId: the open one again, or a new pending row. */
-function openDirect({ callerId, targetId, channelId }) {
- return openDirectFor(callerId, targetId, channelId) || insert({ kind: 'direct', state: 'pending', channelId, createdBy: callerId, targetId });
+async function openDirect({ callerId, targetId, channelId }) {
+ return await openDirectFor(callerId, targetId, channelId) || await insert({ kind: 'direct', state: 'pending', channelId, createdBy: callerId, targetId });
}
}
/**
/**
* The invite went out: ringing, and missed after `timeoutMs` unless answered first. `onTimeout(row)`
* The invite went out: ringing, and missed after `timeoutMs` unless answered first. `onTimeout(row)`
* runs once the row is missed (the call server tells the caller). Ringing again restarts the timer.
* runs once the row is missed (the call server tells the caller). Ringing again restarts the timer.
*/
*/
-function ring(id, { timeoutMs, onTimeout = null } = {}) {
- let row = get(id);
+async function ring(id, { timeoutMs, onTimeout = null } = {}) {
+ let row = await get(id);
if (!row) return null;
if (!row) return null;
- if (row.state === 'pending') row = transition(id, 'ringing');
+ if (row.state === 'pending') row = await transition(id, 'ringing');
if (!row || row.state !== 'ringing') return row;
if (!row || row.state !== 'ringing') return row;
clearTimer(id);
clearTimer(id);
- const t = setTimeout(() => {
+ const t = setTimeout(async () => {
_timers.delete(id);
_timers.delete(id);
- const missed = transition(id, 'missed', { reason: 'timeout' });
+ const missed = await transition(id, 'missed', { reason: 'timeout' });
if (missed && onTimeout) { try { onTimeout(missed); } catch (err) { console.warn('[Calls] ring timeout:', err.message); } }
if (missed && onTimeout) { try { onTimeout(missed); } catch (err) { console.warn('[Calls] ring timeout:', err.message); } }
}, Math.max(1, Number(timeoutMs) || 0));
}, Math.max(1, Number(timeoutMs) || 0));
if (t.unref) t.unref();
if (t.unref) t.unref();
@@ -120,72 +120,75 @@ function ring(id, { timeoutMs, onTimeout = null } = {}) {
@@ -120,72 +120,75 @@ function ring(id, { timeoutMs, onTimeout = null } = {}) {
}
}
/** The invite could not be delivered. */
/** The invite could not be delivered. */
-function fail(id, reason) {
- return transition(id, 'failed', { reason: String(reason || 'failed').slice(0, 200) });
+async function fail(id, reason) {
+ return await transition(id, 'failed', { reason: String(reason || 'failed').slice(0, 200) });
}
}
/**
/**
* The callee answered (a respond status). Returns the row after the answer, or null when there is
* The callee answered (a respond status). Returns the row after the answer, or null when there is
* no ring to answer (never rang, already over). `accepted` on an active call changes nothing.
* no ring to answer (never rang, already over). `accepted` on an active call changes nothing.
*/
*/
-function respond({ callerId, targetId, channelId, status }) {
+async function respond({ callerId, targetId, channelId, status }) {
const rule = RESPONSES[status];
const rule = RESPONSES[status];
if (!rule) return null;
if (!rule) return null;
- const row = db.get(`SELECT * FROM calls WHERE kind = 'direct' AND created_by = ? AND target_user_id = ? AND channel_id = ?
+ const row = await db.get(`SELECT * FROM calls WHERE kind = 'direct' AND created_by = ? AND target_user_id = ? AND channel_id = ?
AND state IN ('pending', 'ringing', 'active') ORDER BY id DESC LIMIT 1`, [callerId, targetId, String(channelId)]);
AND state IN ('pending', 'ringing', 'active') ORDER BY id DESC LIMIT 1`, [callerId, targetId, String(channelId)]);
if (!row) return null;
if (!row) return null;
if (row.state === 'active') return status === 'accepted' ? row : null;
if (row.state === 'active') return status === 'accepted' ? row : null;
- return transition(row.id, rule[0], { reason: rule[1] });
+ return await transition(row.id, rule[0], { reason: rule[1] });
}
}
/** The callee joined the caller's channel while it rang: that is an answer too. */
/** The callee joined the caller's channel while it rang: that is an answer too. */
-function answeredByJoin(channelId, userId) {
+async function answeredByJoin(channelId, userId) {
if (!userId) return [];
if (!userId) return [];
- const rows = db.all(`SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND target_user_id = ? AND state = 'ringing'`, [String(channelId), userId]);
- return rows.map((r) => transition(r.id, 'active')).filter(Boolean);
+ const rows = await db.all(`SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND target_user_id = ? AND state = 'ringing'`, [String(channelId), userId]);
+ const out = []; for (const r of rows) out.push(await transition(r.id, 'active'));
+ return out.filter(Boolean);
}
}
// ── Channel sessions ────────────────────────────────────────────────────────────────────────────
// ── Channel sessions ────────────────────────────────────────────────────────────────────────────
/** Somebody is in the channel: its session is active (one per channel at a time). */
/** Somebody is in the channel: its session is active (one per channel at a time). */
-function sessionStarted({ channelId, streamId = null, createdBy = null }) {
- const open = db.get(`SELECT * FROM calls WHERE kind IN ('channel', 'stream') AND channel_id = ? AND state = 'active' ORDER BY id DESC LIMIT 1`, [String(channelId)]);
+async function sessionStarted({ channelId, streamId = null, createdBy = null }) {
+ const open = await db.get(`SELECT * FROM calls WHERE kind IN ('channel', 'stream') AND channel_id = ? AND state = 'active' ORDER BY id DESC LIMIT 1`, [String(channelId)]);
if (open) return open;
if (open) return open;
- return insert({ kind: streamId ? 'stream' : 'channel', state: 'active', channelId, streamId, createdBy });
+ return await insert({ kind: streamId ? 'stream' : 'channel', state: 'active', channelId, streamId, createdBy });
}
}
/** Nobody is left (or everyone was sent away): the session and every answered ring on it end. */
/** Nobody is left (or everyone was sent away): the session and every answered ring on it end. */
-function channelEmptied(channelId, reason = 'empty') {
- const rows = db.all(`SELECT id FROM calls WHERE channel_id = ? AND state = 'active'`, [String(channelId)]);
- return rows.map((r) => transition(r.id, 'ended', { reason })).filter(Boolean);
+async function channelEmptied(channelId, reason = 'empty') {
+ const rows = await db.all(`SELECT id FROM calls WHERE channel_id = ? AND state = 'active'`, [String(channelId)]);
+ const out = []; for (const r of rows) out.push(await transition(r.id, 'ended', { reason }));
+ return out.filter(Boolean);
}
}
/** The channel is gone (deleted, ended, stream over): rings still waiting on it end as canceled. */
/** The channel is gone (deleted, ended, stream over): rings still waiting on it end as canceled. */
-function channelClosed(channelId, reason = 'closed') {
- const ended = channelEmptied(channelId, reason);
- const rows = db.all(`SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND state IN ('pending', 'ringing')`, [String(channelId)]);
- return ended.concat(rows.map((r) => transition(r.id, 'ended', { reason: 'canceled' })).filter(Boolean));
+async function channelClosed(channelId, reason = 'closed') {
+ const ended = await channelEmptied(channelId, reason);
+ const rows = await db.all(`SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND state IN ('pending', 'ringing')`, [String(channelId)]);
+ for (const r of rows) ended.push(await transition(r.id, 'ended', { reason: 'canceled' }));
+ return ended.filter(Boolean);
}
}
// ── Boot ────────────────────────────────────────────────────────────────────────────────────────
// ── Boot ────────────────────────────────────────────────────────────────────────────────────────
/** After a restart no room survives: close whatever the previous process left open. */
/** After a restart no room survives: close whatever the previous process left open. */
-function recover() {
+async function recover() {
const out = { failed: 0, missed: 0, ended: 0 };
const out = { failed: 0, missed: 0, ended: 0 };
- for (const r of db.all(`SELECT id, state FROM calls WHERE state IN ('pending', 'ringing', 'active')`)) {
+ for (const r of await db.all(`SELECT id, state FROM calls WHERE state IN ('pending', 'ringing', 'active')`)) {
const to = r.state === 'pending' ? 'failed' : r.state === 'ringing' ? 'missed' : 'ended';
const to = r.state === 'pending' ? 'failed' : r.state === 'ringing' ? 'missed' : 'ended';
- if (transition(r.id, to, { reason: 'restart' })) out[to]++;
+ if (await transition(r.id, to, { reason: 'restart' })) out[to]++;
}
}
if (out.failed + out.missed + out.ended) console.log(`[Calls] closed calls left open by the last run: ${JSON.stringify(out)}`);
if (out.failed + out.missed + out.ended) console.log(`[Calls] closed calls left open by the last run: ${JSON.stringify(out)}`);
return out;
return out;
}
}
-function list({ channelId = null, kind = null, limit = 50 } = {}) {
+async function list({ channelId = null, kind = null, limit = 50 } = {}) {
const where = [];
const where = [];
const params = [];
const params = [];
if (channelId) { where.push('channel_id = ?'); params.push(String(channelId)); }
if (channelId) { where.push('channel_id = ?'); params.push(String(channelId)); }
if (kind) { where.push('kind = ?'); params.push(kind); }
if (kind) { where.push('kind = ?'); params.push(kind); }
- return db.all(`SELECT * FROM calls ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`, [...params, Math.min(Math.max(Number(limit) || 50, 1), 500)]);
+ return await db.all(`SELECT * FROM calls ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`, [...params, Math.min(Math.max(Number(limit) || 50, 1), 500)]);
}
}
function stop() { for (const id of [..._timers.keys()]) clearTimer(id); }
function stop() { for (const id of [..._timers.keys()]) clearTimer(id); }
@@ -44,19 +44,19 @@ const MODES = ['mic', 'mic+cam', 'cam+mic'];
@@ -44,19 +44,19 @@ const MODES = ['mic', 'mic+cam', 'cam+mic'];
/* ── Voice Channels (global, non-stream) ───────────────────── */
/* ── Voice Channels (global, non-stream) ───────────────────── */
-router.get('/voice-channels', optionalAuth, (req, res) => {
+router.get('/voice-channels', optionalAuth, async (req, res) => {
try {
try {
res.set('Cache-Control', 'no-store'); // private calls differ per viewer; the list is pushed anyway
res.set('Cache-Control', 'no-store'); // private calls differ per viewer; the list is pushed anyway
- res.json({ channels: callServer.listChannels(req.user || null) });
+ res.json({ channels: await callServer.listChannels(req.user || null) });
} catch (err) {
} catch (err) {
console.error('[Calls]', err.message);
console.error('[Calls]', err.message);
res.status(500).json({ error: 'Failed to list voice channels' });
res.status(500).json({ error: 'Failed to list voice channels' });
}
}
});
});
-router.get('/voice-channels/:channelId', optionalAuth, (req, res) => {
+router.get('/voice-channels/:channelId', optionalAuth, async (req, res) => {
try {
try {
- const ch = callServer.getChannel(req.params.channelId, req.user || null);
+ const ch = await callServer.getChannel(req.params.channelId, req.user || null);
if (!ch) return res.status(404).json({ error: 'Channel not found' });
if (!ch) return res.status(404).json({ error: 'Channel not found' });
res.json({ channel: ch });
res.json({ channel: ch });
} catch (err) {
} catch (err) {
@@ -77,9 +77,9 @@ router.post('/voice-channels', requireAuth, channelLimit, (req, res) => {
@@ -77,9 +77,9 @@ router.post('/voice-channels', requireAuth, channelLimit, (req, res) => {
}
}
});
});
-router.delete('/voice-channels/:channelId', requireAuth, channelLimit, (req, res) => {
+router.delete('/voice-channels/:channelId', requireAuth, channelLimit, async (req, res) => {
try {
try {
- const ok = callServer.deleteChannel(req.params.channelId, req.user);
+ const ok = await callServer.deleteChannel(req.params.channelId, req.user);
if (!ok) return res.status(403).json({ error: 'Cannot delete this channel' });
if (!ok) return res.status(403).json({ error: 'Cannot delete this channel' });
res.json({ deleted: true });
res.json({ deleted: true });
} catch (err) {
} catch (err) {
@@ -92,16 +92,16 @@ router.delete('/voice-channels/:channelId', requireAuth, channelLimit, (req, res
@@ -92,16 +92,16 @@ router.delete('/voice-channels/:channelId', requireAuth, channelLimit, (req, res
async function findUser(id, username) {
async function findUser(id, username) {
let user = null;
let user = null;
if (id > 0) {
if (id > 0) {
- user = ctx.getUserById(id);
- if (!user) { await ctx.ensureUsers([id]).catch(() => {}); user = ctx.getUserById(id); }
+ user = await ctx.getUserById(id);
+ if (!user) { await ctx.ensureUsers([id]).catch(() => {}); user = await ctx.getUserById(id); }
}
}
if (!user && username) user = await ctx.ensureUserByUsername(username);
if (!user && username) user = await ctx.ensureUserByUsername(username);
return user || null;
return user || null;
}
}
/** The caller hears "no answer" when their ring times out (the frame the callee's browser sends). */
/** The caller hears "no answer" when their ring times out (the frame the callee's browser sends). */
-function tellCallerNoAnswer(row, channelName, target) {
- chatServer.sendDm(row.created_by, {
+async function tellCallerNoAnswer(row, channelName, target) {
+ await chatServer.sendDm(row.created_by, {
type: 'vc-call-response',
type: 'vc-call-response',
status: 'no-answer',
status: 'no-answer',
channelId: row.channel_id,
channelId: row.channel_id,
@@ -129,11 +129,11 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res
@@ -129,11 +129,11 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res
const targetUser = await findUser(targetUserId, targetUsername);
const targetUser = await findUser(targetUserId, targetUsername);
if (!targetUser) return res.status(404).json({ error: 'User not found' });
if (!targetUser) return res.status(404).json({ error: 'User not found' });
if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });
if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });
- try { if (dm.isBlockedEither && dm.isBlockedEither(req.user.id, targetUser.id)) return res.status(403).json({ error: 'You cannot call this user' }); } catch { /* */ }
+ try { if (dm.isBlockedEither && await dm.isBlockedEither(req.user.id, targetUser.id)) return res.status(403).json({ error: 'You cannot call this user' }); } catch { /* */ }
// Reuse caller's existing temp channel if present; otherwise create a private one — a
// Reuse caller's existing temp channel if present; otherwise create a private one — a
// 1:1 call is not something the whole site should see listed and be able to walk into.
// 1:1 call is not something the whole site should see listed and be able to walk into.
- const existing = (callServer.listChannels(req.user) || []).find(ch => !ch.permanent && !ch.streamId && ch.createdBy === req.user.id) || null;
+ const existing = (await callServer.listChannels(req.user) || []).find(ch => !ch.permanent && !ch.streamId && ch.createdBy === req.user.id) || null;
const channel = existing || callServer.createChannel({
const channel = existing || callServer.createChannel({
name: `${req.user.display_name || req.user.username}'s call`,
name: `${req.user.display_name || req.user.username}'s call`,
mode: 'mic+cam',
mode: 'mic+cam',
@@ -141,7 +141,7 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res
@@ -141,7 +141,7 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res
maxParticipants: 8,
maxParticipants: 8,
isPrivate: true,
isPrivate: true,
});
});
- call = lifecycle.openDirect({ callerId: req.user.id, targetId: targetUser.id, channelId: channel.id });
+ call = await lifecycle.openDirect({ callerId: req.user.id, targetId: targetUser.id, channelId: channel.id });
if (!callServer.invite(channel.id, targetUser.id)) throw new Error('the call channel is gone');
if (!callServer.invite(channel.id, targetUser.id)) throw new Error('the call channel is gone');
const callerName = req.user.display_name || req.user.username || 'Someone';
const callerName = req.user.display_name || req.user.username || 'Someone';
@@ -157,7 +157,7 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res
@@ -157,7 +157,7 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res
};
};
// Real-time invite for online users via existing chat WS connections.
// Real-time invite for online users via existing chat WS connections.
- chatServer.sendDm(targetUser.id, payload);
+ await chatServer.sendDm(targetUser.id, payload);
// Persistent cross-site notification for offline users / later join (Live pushes it).
// Persistent cross-site notification for offline users / later join (Live pushes it).
ctx.effects.notifyCallInvite({
ctx.effects.notifyCallInvite({
@@ -167,18 +167,18 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res
@@ -167,18 +167,18 @@ router.post('/voice-channels/call-user', requireAuth, ringLimit, async (req, res
channel_name: channel.name,
channel_name: channel.name,
});
});
- lifecycle.ring(call.id, { timeoutMs: config.calls.ringTimeoutMs, onTimeout: (row) => tellCallerNoAnswer(row, channel.name, targetUser) });
+ await lifecycle.ring(call.id, { timeoutMs: config.calls.ringTimeoutMs, onTimeout: async (row) => await tellCallerNoAnswer(row, channel.name, targetUser) });
return res.json({ invited: true, reusedChannel: !!existing, channel });
return res.json({ invited: true, reusedChannel: !!existing, channel });
} catch (err) {
} catch (err) {
if (err.code === 'CHANNEL_LIMIT') return res.status(400).json({ error: err.message });
if (err.code === 'CHANNEL_LIMIT') return res.status(400).json({ error: err.message });
- if (call) { try { lifecycle.fail(call.id, `invite_failed: ${err.message}`); } catch { /* */ } }
+ if (call) { try { await lifecycle.fail(call.id, `invite_failed: ${err.message}`); } catch { /* */ } }
console.error('[Calls]', err.message);
console.error('[Calls]', err.message);
return res.status(500).json({ error: 'Failed to call user' });
return res.status(500).json({ error: 'Failed to call user' });
}
}
});
});
-router.post('/voice-channels/call-user/respond', requireAuth, answerLimit, (req, res) => {
+router.post('/voice-channels/call-user/respond', requireAuth, answerLimit, async (req, res) => {
try {
try {
const callerUserId = Number(req.body?.caller_user_id || 0);
const callerUserId = Number(req.body?.caller_user_id || 0);
const channelId = String(req.body?.channel_id || '').trim();
const channelId = String(req.body?.channel_id || '').trim();
@@ -191,13 +191,13 @@ router.post('/voice-channels/call-user/respond', requireAuth, answerLimit, (req,
@@ -191,13 +191,13 @@ router.post('/voice-channels/call-user/respond', requireAuth, answerLimit, (req,
if (!allowed.has(status)) return res.status(400).json({ error: 'Invalid response status' });
if (!allowed.has(status)) return res.status(400).json({ error: 'Invalid response status' });
if (callerUserId === req.user.id) return res.status(400).json({ error: 'Invalid caller target' });
if (callerUserId === req.user.id) return res.status(400).json({ error: 'Invalid caller target' });
// Only an invited user can answer, and only to the caller who owns that channel.
// Only an invited user can answer, and only to the caller who owns that channel.
- const ch = callServer.getChannel(channelId, req.user);
+ const ch = await callServer.getChannel(channelId, req.user);
if (!ch || ch.createdBy !== callerUserId || !callServer.hasInvite(channelId, req.user.id)) return res.status(403).json({ error: 'No such invite' });
if (!ch || ch.createdBy !== callerUserId || !callServer.hasInvite(channelId, req.user.id)) return res.status(403).json({ error: 'No such invite' });
- try { lifecycle.respond({ callerId: callerUserId, targetId: req.user.id, channelId, status }); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }
+ try { await lifecycle.respond({ callerId: callerUserId, targetId: req.user.id, channelId, status }); } catch (err) { console.warn('[Calls] lifecycle:', err.message); }
const fromDisplayName = req.user.display_name || req.user.username || 'Someone';
const fromDisplayName = req.user.display_name || req.user.username || 'Someone';
- chatServer.sendDm(callerUserId, {
+ await chatServer.sendDm(callerUserId, {
type: 'vc-call-response',
type: 'vc-call-response',
status,
status,
channelId,
channelId,
@@ -248,9 +248,9 @@ router.put('/:id/call', requireAuth, channelLimit, async (req, res) => {
@@ -248,9 +248,9 @@ router.put('/:id/call', requireAuth, channelLimit, async (req, res) => {
// Create or remove stream voice channel
// Create or remove stream voice channel
if (call_mode) {
if (call_mode) {
- callServer.createStreamChannel(stream.id, call_mode, stream.user_id, stream);
+ await callServer.createStreamChannel(stream.id, call_mode, stream.user_id, stream);
} else {
} else {
- callServer.removeStreamChannel(stream.id);
+ await callServer.removeStreamChannel(stream.id);
}
}
res.json(callStatus(stream.id, call_mode));
res.json(callStatus(stream.id, call_mode));
@@ -262,7 +262,7 @@ router.put('/:id/call', requireAuth, channelLimit, async (req, res) => {
@@ -262,7 +262,7 @@ router.put('/:id/call', requireAuth, channelLimit, async (req, res) => {
router.get('/:id/call', optionalAuth, async (req, res) => {
router.get('/:id/call', optionalAuth, async (req, res) => {
try {
try {
- const stream = ctx.getStreamById(req.params.id) || await ctx.ensureStream(req.params.id);
+ const stream = await ctx.getStreamById(req.params.id) || await ctx.ensureStream(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
if (!stream) return res.status(404).json({ error: 'Stream not found' });
const ch = callServer.channels.get(`stream-${stream.id}`);
const ch = callServer.channels.get(`stream-${stream.id}`);
res.json(callStatus(stream.id, ch ? ch.mode : null));
res.json(callStatus(stream.id, ch ? ch.mode : null));
@@ -30,58 +30,39 @@ const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -30,58 +30,39 @@ const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
const TOPICS = ['network.account.export_requested', 'network.account.deleted'];
const TOPICS = ['network.account.export_requested', 'network.account.deleted'];
const ROW_LIMIT = 5000;
const ROW_LIMIT = 5000;
-const cols = (d, t) => { try { return d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name); } catch { return []; } };
-const has = (d, t, c) => cols(d, t).includes(c);
-const inList = (xs) => `(${xs.map(() => '?').join(',')})`;
-
-function ensureSchema(d) {
- d.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL,
- subject TEXT NOT NULL,
- outcome TEXT,
- sent_at TEXT,
- applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
- )`);
-}
+// Static statements per table (plan T3 decision 7: no interpolated identifiers). @s is the person's subjects,
+// @ids their Live user ids (either can be empty: `= ANY('{}')` matches nothing).
/** The Live user ids of these subjects (Chat's ctx_users mirror). */
/** The Live user ids of these subjects (Chat's ctx_users mirror). */
-function liveIds(d, subjects) {
- return d.prepare(`SELECT id FROM ctx_users WHERE subject_id IN ${inList(subjects)}`).all(...subjects).map((r) => r.id);
-}
-
-/** WHERE for a table: its subject column(s) IN subjects, or its Live user column(s) IN ids. → [sql, params] | null. */
-function whereFor(d, table, subjectCols, idCols, subjects, ids) {
- const parts = []; const params = [];
- for (const c of subjectCols) if (has(d, table, c)) { parts.push(`${c} IN ${inList(subjects)}`); params.push(...subjects); }
- if (ids.length) for (const c of idCols) if (has(d, table, c)) { parts.push(`${c} IN ${inList(ids)}`); params.push(...ids); }
- return parts.length ? [parts.join(' OR '), params] : null;
+async function liveIds(d, subjects) {
+ return (await d.prepare('SELECT id FROM ctx_users WHERE subject_id = ANY(?)').all(subjects)).map((r) => r.id);
}
}
// ── Export ─────────────────────────────────────────────────────
// ── Export ─────────────────────────────────────────────────────
+// [file, rows]: at most ROW_LIMIT + 1 of the most recent (highest key first; SQLite read these by rowid).
+const LIMIT = ROW_LIMIT + 1;
const EXPORTS = [
const EXPORTS = [
- ['chat_messages.json', 'chat_messages', ['subject_id'], ['user_id']],
- ['room_messages.json', 'room_messages', ['subject_id'], ['user_id']],
- ['direct_messages.json', 'dm_messages', ['sender_subject_id'], ['sender_id']],
- ['conversations.json', 'dm_participants', ['subject_id'], ['user_id']],
- ['rooms_owned.json', 'rooms', ['owner_subject'], ['owner_id']],
- ['calls.json', 'calls', ['created_by_subject', 'target_subject'], ['created_by', 'target_user_id']],
- ['blocks.json', 'network_blocks', ['blocker_subject'], []],
- ['dm_blocks.json', 'dm_blocks', ['blocker_subject_id'], ['blocker_id']],
- ['emotes.json', 'emotes', [], ['user_id']],
- ['sounds.json', 'channel_sounds', ['created_by_subject_id'], ['created_by']],
- ['moderation_on_you.json', 'moderation_actions', [], ['target_user_id']],
+ ['chat_messages.json', `SELECT * FROM chat_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['room_messages.json', `SELECT * FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['direct_messages.json', `SELECT * FROM dm_messages WHERE sender_subject_id = ANY(@s) OR sender_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['conversations.json', `SELECT * FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['rooms_owned.json', `SELECT * FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],
+ ['dm_blocks.json', `SELECT * FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['emotes.json', `SELECT * FROM emotes WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['sounds.json', `SELECT * FROM channel_sounds WHERE created_by_subject_id = ANY(@s) OR created_by = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['moderation_on_you.json', `SELECT * FROM moderation_actions WHERE target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
];
];
-function exportPart(d, subject) {
- const ids = liveIds(d, [subject]);
+async function exportPart(d, subject) {
+ const ids = await liveIds(d, [subject]);
const files = []; const truncated = [];
const files = []; const truncated = [];
- for (const [name, table, sc, ic] of EXPORTS) {
- const w = whereFor(d, table, sc, ic, [subject], ids);
- if (!w) continue;
- let rows = d.prepare(`SELECT * FROM ${table} WHERE ${w[0]} ORDER BY rowid DESC LIMIT ${ROW_LIMIT + 1}`).all(...w[1]);
- if (table === 'moderation_actions') rows = rows.map(({ actor_user_id, actor_subject_id, ...r }) => r); // who acted is staff's, not theirs
+ for (const [name, text] of EXPORTS) {
+ let rows = await d.prepare(text).all({ s: [subject], ids });
+ if (name === 'chat_messages.json') rows = rows.map(db.chatMessageRow);
+ if (name === 'moderation_on_you.json') rows = rows.map(({ actor_user_id, actor_subject_id, ...r }) => db.moderationRow(r)); // who acted is staff's, not theirs
if (!rows.length) continue;
if (!rows.length) continue;
if (rows.length > ROW_LIMIT) truncated.push(name);
if (rows.length > ROW_LIMIT) truncated.push(name);
files.push({ name, content: rows.slice(0, ROW_LIMIT) });
files.push({ name, content: rows.slice(0, ROW_LIMIT) });
@@ -91,61 +72,66 @@ function exportPart(d, subject) {
@@ -91,61 +72,66 @@ function exportPart(d, subject) {
// ── Deletion ───────────────────────────────────────────────────
// ── Deletion ───────────────────────────────────────────────────
-function erase(d, subjects) {
- const ids = liveIds(d, subjects);
+// [erased key, DELETE]: in this order, in one transaction.
+const ERASE_FIRST = [
+ ['messages', 'DELETE FROM chat_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids)'],
+ ['room_messages', 'DELETE FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids)'],
+ ['direct_messages', 'DELETE FROM dm_messages WHERE sender_subject_id = ANY(@s) OR sender_id = ANY(@ids)'],
+ ['conversations_left', 'DELETE FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids)'],
+ ['conversations', 'DELETE FROM dm_conversations WHERE id NOT IN (SELECT conversation_id FROM dm_participants)'],
+ ['room_memberships', 'DELETE FROM room_members WHERE user_id = ANY(@ids)'],
+ ['room_attachments', 'DELETE FROM room_attachments WHERE attached_by_subject = ANY(@s)'],
+];
+const ERASE_AFTER_ROOMS = [
+ ['calls', 'DELETE FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids)'],
+ ['blocks', 'DELETE FROM network_blocks WHERE blocker_subject = ANY(@s) OR blocked_subject = ANY(@s)'],
+ ['blocks', 'DELETE FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) OR blocked_id = ANY(@ids)'],
+ // A person's own summaries and timeline are keyed by their Live id (scope 'user').
+ ['ai_summaries', "DELETE FROM chat_ai_summaries WHERE scope = 'user' AND subject_id = ANY(@ids)"],
+ ['timeline', "DELETE FROM chat_timeline_events WHERE scope = 'user' AND subject_id = ANY(@ids)"],
+ ['tags', 'DELETE FROM user_tags WHERE user_id = ANY(@ids)'],
+ ['moderator_roles', 'DELETE FROM channel_moderators WHERE user_id = ANY(@ids)'],
+ ['pending_messages', 'DELETE FROM pending_ip_messages WHERE user_id = ANY(@ids)'],
+];
+// Their channel (only when they have Live ids): emotes, sounds, moderators, stats, audio requests, the Live mirrors.
+const ERASE_CHANNEL = [
+ ['moderator_roles', 'DELETE FROM channel_moderators WHERE channel_id IN (SELECT id FROM ctx_channels WHERE user_id = ANY(@ids))'],
+ [null, 'DELETE FROM channel_moderation_settings WHERE channel_id IN (SELECT id FROM ctx_channels WHERE user_id = ANY(@ids))'],
+ ['emotes', 'DELETE FROM emotes WHERE user_id = ANY(@ids)'],
+ ['sounds', 'DELETE FROM channel_sounds WHERE created_by_subject_id = ANY(@s) OR created_by = ANY(@ids)'],
+ ['channel_stats', 'DELETE FROM stream_first_chats WHERE channel_user_id = ANY(@ids)'],
+ ['audio_requests', 'DELETE FROM audio_requests WHERE channel_user_id = ANY(@ids)'],
+ ['live_mirror', 'DELETE FROM ctx_channels WHERE user_id = ANY(@ids)'],
+ ['live_mirror', 'DELETE FROM ctx_streams WHERE user_id = ANY(@ids)'],
+ ['live_mirror', 'DELETE FROM ctx_managed_streams WHERE user_id = ANY(@ids)'],
+];
+
+async function erase(d, subjects) {
+ const ids = await liveIds(d, subjects);
+ const p = { s: subjects, ids };
const erased = {}; const retained = {};
const erased = {}; const retained = {};
- const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
- const del = (key, table, sc, ic) => {
- const w = whereFor(d, table, sc, ic, subjects, ids);
- if (!w) return;
- add(erased, key, d.prepare(`DELETE FROM ${table} WHERE ${w[0]}`).run(...w[1]).changes);
- };
- d.transaction(() => {
- del('messages', 'chat_messages', ['subject_id'], ['user_id']);
- del('room_messages', 'room_messages', ['subject_id'], ['user_id']);
- del('direct_messages', 'dm_messages', ['sender_subject_id'], ['sender_id']);
- del('conversations_left', 'dm_participants', ['subject_id'], ['user_id']);
- if (has(d, 'dm_conversations', 'id')) add(erased, 'conversations', d.prepare('DELETE FROM dm_conversations WHERE id NOT IN (SELECT conversation_id FROM dm_participants)').run().changes);
- del('room_memberships', 'room_members', [], ['user_id']);
- del('room_attachments', 'room_attachments', ['attached_by_subject'], []);
- const owned = whereFor(d, 'rooms', ['owner_subject'], ['owner_id'], subjects, ids);
- if (owned) {
- for (const r of d.prepare(`SELECT id FROM rooms WHERE ${owned[0]}`).all(...owned[1])) {
- const others = has(d, 'room_members', 'room_id') && d.prepare('SELECT 1 FROM room_members WHERE room_id = ? LIMIT 1').get(r.id);
- if (others) { d.prepare('UPDATE rooms SET owner_subject = NULL WHERE id = ?').run(r.id); add(retained, 'rooms_without_owner', 1); }
- else {
- if (has(d, 'room_messages', 'room_id')) d.prepare('DELETE FROM room_messages WHERE room_id = ?').run(r.id);
- d.prepare('DELETE FROM rooms WHERE id = ?').run(r.id);
- add(erased, 'rooms', 1);
- }
+ const add = (o, k, n) => { if (k && n) o[k] = (o[k] || 0) + n; };
+ const del = async ([key, text]) => { const st = d.prepare(text); add(erased, key, (await (text.includes('@') ? st.run(p) : st.run())).changes); };
+ await d.tx(async () => {
+ for (const e of ERASE_FIRST) await del(e);
+ for (const r of await d.prepare('SELECT id FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)').all(p)) {
+ const others = await d.prepare('SELECT 1 FROM room_members WHERE room_id = ? LIMIT 1').get(r.id);
+ if (others) { await d.prepare('UPDATE rooms SET owner_subject = NULL WHERE id = ?').run(r.id); add(retained, 'rooms_without_owner', 1); }
+ else {
+ await d.prepare('DELETE FROM room_messages WHERE room_id = ?').run(r.id);
+ await d.prepare('DELETE FROM rooms WHERE id = ?').run(r.id);
+ add(erased, 'rooms', 1);
}
}
}
}
- del('calls', 'calls', ['created_by_subject', 'target_subject'], ['created_by', 'target_user_id']);
- del('blocks', 'network_blocks', ['blocker_subject', 'blocked_subject'], []);
- del('blocks', 'dm_blocks', ['blocker_subject_id'], ['blocker_id', 'blocked_id']);
- del('ai_summaries', 'chat_ai_summaries', ['subject_id'], []);
- del('timeline', 'chat_timeline_events', ['subject_id'], []);
- del('tags', 'user_tags', [], ['user_id']);
- del('moderator_roles', 'channel_moderators', [], ['user_id']);
- del('pending_messages', 'pending_ip_messages', [], ['user_id']);
+ for (const e of ERASE_AFTER_ROOMS) await del(e);
if (ids.length) {
if (ids.length) {
- // Their channel: emotes, sounds, moderators, stats, audio requests, then the Live mirrors.
- const channels = has(d, 'ctx_channels', 'user_id') ? d.prepare(`SELECT id FROM ctx_channels WHERE user_id IN ${inList(ids)}`).all(...ids).map((r) => r.id) : [];
- if (channels.length && has(d, 'channel_moderators', 'channel_id')) add(erased, 'moderator_roles', d.prepare(`DELETE FROM channel_moderators WHERE channel_id IN ${inList(channels)}`).run(...channels).changes);
- if (channels.length && has(d, 'channel_moderation_settings', 'channel_id')) d.prepare(`DELETE FROM channel_moderation_settings WHERE channel_id IN ${inList(channels)}`).run(...channels);
- del('emotes', 'emotes', [], ['user_id']);
- del('sounds', 'channel_sounds', ['created_by_subject_id'], ['created_by']);
- del('channel_stats', 'stream_first_chats', [], ['channel_user_id']);
- del('audio_requests', 'audio_requests', [], ['channel_user_id']);
- for (const t of ['ctx_channels', 'ctx_streams', 'ctx_managed_streams']) del('live_mirror', t, [], ['user_id']);
- const set = ['username', 'display_name', 'avatar_url', 'profile_color', 'ban_reason'].filter((c) => has(d, 'ctx_users', c));
- for (const id of ids) {
- d.prepare(`UPDATE ctx_users SET ${set.map((c) => `${c} = ${c === 'username' ? '?' : 'NULL'}`).join(', ')} WHERE id = ?`).run(`deleted-${id}`, id);
- }
+ for (const e of ERASE_CHANNEL) await del(e);
+ await d.prepare(`UPDATE ctx_users SET username = 'deleted-' || id, display_name = NULL, avatar_url = NULL, profile_color = NULL, ban_reason = NULL
+ WHERE id = ANY(?)`).run(ids);
}
}
- const mod = whereFor(d, 'moderation_actions', ['actor_subject_id'], ['target_user_id', 'actor_user_id'], subjects, ids);
- if (mod) add(retained, 'moderation_actions', d.prepare(`SELECT COUNT(*) AS n FROM moderation_actions WHERE ${mod[0]}`).get(...mod[1]).n);
- })();
+ const n = await d.prepare('SELECT COUNT(*) AS n FROM moderation_actions WHERE actor_subject_id = ANY(@s) OR target_user_id = ANY(@ids) OR actor_user_id = ANY(@ids)').get(p);
+ add(retained, 'moderation_actions', n.n);
+ });
return { erased, retained, ids };
return { erased, retained, ids };
}
}
@@ -168,30 +154,30 @@ async function apply(ev, { send = networkCall, log = console, onErased = null }
@@ -168,30 +154,30 @@ async function apply(ev, { send = networkCall, log = console, onErased = null }
if (ev.source !== 'network') return 'ignored:source';
if (ev.source !== 'network') return 'ignored:source';
const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
const d = db.getDb();
const d = db.getDb();
- ensureSchema(d);
if (ev.event_type === 'network.account.export_requested') {
if (ev.event_type === 'network.account.export_requested') {
if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
- const seen = d.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);
+ const seen = await d.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);
if (seen && seen.sent_at) return 'unchanged';
if (seen && seen.sent_at) return 'unchanged';
- const part = exportPart(d, p.subject);
+ const part = await exportPart(d, p.subject);
const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });
const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });
if (!res) throw new Error('Network unreachable');
if (!res) throw new Error('Network unreachable');
const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);
const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);
if (!outcome) throw new Error(`export part refused: ${res.status}`);
if (!outcome) throw new Error(`export part refused: ${res.status}`);
- d.prepare('INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)')
+ await d.prepare(`INSERT INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, subject = excluded.subject, outcome = excluded.outcome, sent_at = excluded.sent_at, applied_at = ov_now_iso()`)
.run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, files: part.files.length }), new Date().toISOString());
.run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, files: part.files.length }), new Date().toISOString());
return outcome;
return outcome;
}
}
if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
- let rec = d.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
+ let rec = await d.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
let result = 'confirmed';
let result = 'confirmed';
if (!rec) {
if (!rec) {
const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];
const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];
- const { ids, ...counts } = erase(d, subjects);
- d.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));
+ const { ids, ...counts } = await erase(d, subjects);
+ await d.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));
log.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);
log.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);
if (onErased) { try { onErased(subjects, ids); } catch { /* caches refill anyway */ } }
if (onErased) { try { onErased(subjects, ids); } catch { /* caches refill anyway */ } }
- rec = d.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
+ rec = await d.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
result = 'erased';
result = 'erased';
}
}
if (rec.sent_at) return 'unchanged';
if (rec.sent_at) return 'unchanged';
@@ -199,8 +185,11 @@ async function apply(ev, { send = networkCall, log = console, onErased = null }
@@ -199,8 +185,11 @@ async function apply(ev, { send = networkCall, log = console, onErased = null }
const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });
const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });
if (!res) throw new Error('Network unreachable');
if (!res) throw new Error('Network unreachable');
if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);
if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);
- d.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);
+ await d.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);
return result;
return result;
}
}
+// The table is in migrations/0001_initial.sql; kept for callers.
+function ensureSchema() {}
+
module.exports = { apply, exportPart, erase, ensureSchema, TOPICS };
module.exports = { apply, exportPart, erase, ensureSchema, TOPICS };
@@ -26,9 +26,9 @@ const db = require('../db/database');
@@ -26,9 +26,9 @@ const db = require('../db/database');
const ctx = require('../live-context');
const ctx = require('../live-context');
const chatAi = require('../ai/chat-ai');
const chatAi = require('../ai/chat-ai');
-router.get('/global', (req, res) => {
+router.get('/global', async (req, res) => {
try {
try {
- const insight = chatAi.getGlobalInsight();
+ const insight = await chatAi.getGlobalInsight();
res.json({ insight: insight || null });
res.json({ insight: insight || null });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to load global chat insight' });
res.status(500).json({ error: 'Failed to load global chat insight' });
@@ -36,11 +36,11 @@ router.get('/global', (req, res) => {
@@ -36,11 +36,11 @@ router.get('/global', (req, res) => {
});
});
// Browsable/searchable global timeline. Query: before=<ms>, since=<ms>, q=<search>, limit=<n>.
// Browsable/searchable global timeline. Query: before=<ms>, since=<ms>, q=<search>, limit=<n>.
-router.get('/timeline', (req, res) => {
+router.get('/timeline', async (req, res) => {
try {
try {
const num = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; };
const num = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; };
const limit = Math.min(60, Math.max(1, num(req.query.limit) || 25));
const limit = Math.min(60, Math.max(1, num(req.query.limit) || 25));
- const events = db.getChatTimelineEvents({
+ const events = await db.getChatTimelineEvents({
scope: 'global', subjectId: 0,
scope: 'global', subjectId: 0,
before: num(req.query.before), since: num(req.query.since),
before: num(req.query.before), since: num(req.query.since),
q: req.query.q || null, limit,
q: req.query.q || null, limit,
@@ -51,12 +51,12 @@ router.get('/timeline', (req, res) => {
@@ -51,12 +51,12 @@ router.get('/timeline', (req, res) => {
}
}
});
});
-router.get('/user/:id', (req, res) => {
+router.get('/user/:id', async (req, res) => {
try {
try {
const uid = parseInt(req.params.id, 10);
const uid = parseInt(req.params.id, 10);
if (!Number.isFinite(uid)) return res.status(400).json({ error: 'Invalid user id' });
if (!Number.isFinite(uid)) return res.status(400).json({ error: 'Invalid user id' });
- const user = ctx.getUserById(uid);
- const insight = chatAi.getUserInsight(uid);
+ const user = await ctx.getUserById(uid);
+ const insight = await chatAi.getUserInsight(uid);
// `streamer` (who they are as a broadcaster: overview + stream memories) is Live's stream
// `streamer` (who they are as a broadcaster: overview + stream memories) is Live's stream
// feature, not chat; Chat answers null for it (see README, For Opus). The key is kept so
// feature, not chat; Chat answers null for it (see README, For Opus). The key is kept so
// the response shape matches Live's.
// the response shape matches Live's.
@@ -71,11 +71,11 @@ router.get('/user/:id', (req, res) => {
@@ -71,11 +71,11 @@ router.get('/user/:id', (req, res) => {
});
});
// An anonymous chatter's insight, keyed by their stable anon_id ("anon<N>").
// An anonymous chatter's insight, keyed by their stable anon_id ("anon<N>").
-router.get('/anon/:anonId', (req, res) => {
+router.get('/anon/:anonId', async (req, res) => {
try {
try {
const anonId = String(req.params.anonId || '');
const anonId = String(req.params.anonId || '');
if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });
if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });
- const insight = chatAi.getAnonInsight(anonId);
+ const insight = await chatAi.getAnonInsight(anonId);
res.json({ insight: insight || null, user: { anon_id: anonId, username: anonId } });
res.json({ insight: insight || null, user: { anon_id: anonId, username: anonId } });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to load anon chat insight' });
res.status(500).json({ error: 'Failed to load anon chat insight' });
@@ -83,11 +83,11 @@ router.get('/anon/:anonId', (req, res) => {
@@ -83,11 +83,11 @@ router.get('/anon/:anonId', (req, res) => {
});
});
// A bridged external (relay) chatter's insight, keyed by platform + username.
// A bridged external (relay) chatter's insight, keyed by platform + username.
-router.get('/relay/:platform/:username', (req, res) => {
+router.get('/relay/:platform/:username', async (req, res) => {
try {
try {
- const ru = db.getRelayUser(req.params.platform, req.params.username);
+ const ru = await db.getRelayUser(req.params.platform, req.params.username);
if (!ru) return res.json({ insight: null, user: null });
if (!ru) return res.json({ insight: null, user: null });
- const insight = chatAi.getRelayUserInsight(ru.id);
+ const insight = await chatAi.getRelayUserInsight(ru.id);
res.json({
res.json({
insight: insight || null,
insight: insight || null,
user: { platform: ru.platform, username: ru.display_name || ru.username, message_count: ru.message_count || 0, first_seen: ru.first_seen || null },
user: { platform: ru.platform, username: ru.display_name || ru.username, message_count: ru.message_count || 0, first_seen: ru.first_seen || null },
@@ -101,15 +101,15 @@ router.get('/relay/:platform/:username', (req, res) => {
@@ -101,15 +101,15 @@ router.get('/relay/:platform/:username', (req, res) => {
// overviews, VOD moments, combined overview, session titles) with the chatter insight; the streamer
// overviews, VOD moments, combined overview, session titles) with the chatter insight; the streamer
// half is Live's stream feature and Chat cannot build it (README, For Opus). Chat serves the chat
// half is Live's stream feature and Chat cannot build it (README, For Opus). Chat serves the chat
// half with Live's exact keys so the endpoint and the simple consumer keep working.
// half with Live's exact keys so the endpoint and the simple consumer keep working.
-router.get('/timeline/:username', (req, res) => {
+router.get('/timeline/:username', async (req, res) => {
try {
try {
const uname = String(req.params.username || '').trim();
const uname = String(req.params.username || '').trim();
- const user = ctx.getUserByUsername(uname);
+ const user = await ctx.getUserByUsername(uname);
if (!user) return res.status(404).json({ error: 'Channel not found' });
if (!user) return res.status(404).json({ error: 'Channel not found' });
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 12));
const limit = Math.min(30, Math.max(1, parseInt(req.query.limit, 10) || 12));
let chatInsight = null;
let chatInsight = null;
- try { chatInsight = chatAi.getUserInsight(user.id) || null; } catch { chatInsight = null; }
+ try { chatInsight = await chatAi.getUserInsight(user.id) || null; } catch { chatInsight = null; }
res.json({
res.json({
username: user.username,
username: user.username,
display_name: user.display_name || user.username,
display_name: user.display_name || user.username,
@@ -49,7 +49,7 @@ async function playAlertSound(chatServer, streamerId, streamId, kind) {
@@ -49,7 +49,7 @@ async function playAlertSound(chatServer, streamerId, streamId, kind) {
try {
try {
if (!chatServer || !chatServer.broadcastToChannelRoom) return { played: false, source };
if (!chatServer || !chatServer.broadcastToChannelRoom) return { played: false, source };
const channel = await ctx.ensureChannelForUser(streamerId);
const channel = await ctx.ensureChannelForUser(streamerId);
- const row = channel ? db.getChannelModerationSettingsRow(channel.id) : null;
+ const row = channel ? await db.getChannelModerationSettingsRow(channel.id) : null;
if (!row) return { played: false, source };
if (!row) return { played: false, source };
let disk, mime;
let disk, mime;
if (kind === 'goal') {
if (kind === 'goal') {
@@ -61,7 +61,7 @@ async function playAlertSound(chatServer, streamerId, streamId, kind) {
@@ -61,7 +61,7 @@ async function playAlertSound(chatServer, streamerId, streamId, kind) {
}
}
const snd = readSound(disk, mime);
const snd = readSound(disk, mime);
if (!snd) return { played: false, source };
if (!snd) return { played: false, source };
- chatServer.broadcastToChannelRoom(streamerId, streamId || null, { type: 'soundboard-audio', audio: snd.audio, mimeType: snd.mimeType, source });
+ await chatServer.broadcastToChannelRoom(streamerId, streamId || null, { type: 'soundboard-audio', audio: snd.audio, mimeType: snd.mimeType, source });
return { played: true, source };
return { played: true, source };
} catch (err) {
} catch (err) {
console.warn(`[Alerts] play ${kind} for ${streamerId}: ${err.message}`);
console.warn(`[Alerts] play ${kind} for ${streamerId}: ${err.message}`);
@@ -107,17 +107,17 @@ function estimatePlayMs({ audio, mimeType, seconds, speed } = {}) {
@@ -107,17 +107,17 @@ function estimatePlayMs({ audio, mimeType, seconds, speed } = {}) {
}
}
// ── Store ───────────────────────────────────────────────────────────────────────────────────────
// ── Store ───────────────────────────────────────────────────────────────────────────────────────
-function getRequest(id) { return db.get('SELECT * FROM audio_requests WHERE id = ?', [id]) || null; }
+async function getRequest(id) { return await db.get('SELECT * FROM audio_requests WHERE id = ?', [id]) || null; }
/**
/**
* Move one request along the state machine. Returns true when it moved (the row was in a state
* Move one request along the state machine. Returns true when it moved (the row was in a state
* that allows `to`); false when something else moved it first.
* that allows `to`); false when something else moved it first.
*/
*/
-function transition(id, to, { error = null, actor = null, durationMs = null, now = Date.now() } = {}) {
+async function transition(id, to, { error = null, actor = null, durationMs = null, now = Date.now() } = {}) {
const from = Object.keys(TRANSITIONS).filter((s) => TRANSITIONS[s].includes(to));
const from = Object.keys(TRANSITIONS).filter((s) => TRANSITIONS[s].includes(to));
if (!from.length) return false;
if (!from.length) return false;
const final = to !== 'playing';
const final = to !== 'playing';
- const r = db.run(
+ const r = await db.run(
`UPDATE audio_requests SET state = ?, error = COALESCE(?, error), actor = COALESCE(?, actor),
`UPDATE audio_requests SET state = ?, error = COALESCE(?, error), actor = COALESCE(?, actor),
duration_ms = COALESCE(?, duration_ms),
duration_ms = COALESCE(?, duration_ms),
started_at = CASE WHEN ? = 'playing' THEN ? ELSE started_at END,
started_at = CASE WHEN ? = 'playing' THEN ? ELSE started_at END,
@@ -135,7 +135,7 @@ const rooms = new Map(); // room → { busy, timer, playingId }
@@ -135,7 +135,7 @@ const rooms = new Map(); // room → { busy, timer, playingId }
let stopped = false;
let stopped = false;
function roomState(room) {
function roomState(room) {
- if (!rooms.has(room)) rooms.set(room, { busy: false, timer: null, playingId: null, holdUntil: 0, holdTimer: null });
+ if (!rooms.has(room)) rooms.set(room, { busy: false, timer: null, playingId: null, makingId: null, holdUntil: 0, holdTimer: null });
return rooms.get(room);
return rooms.get(room);
}
}
@@ -145,7 +145,7 @@ function holdRoom(room, until) {
@@ -145,7 +145,7 @@ function holdRoom(room, until) {
if (st.holdUntil && st.holdUntil <= until) return;
if (st.holdUntil && st.holdUntil <= until) return;
st.holdUntil = until;
st.holdUntil = until;
if (st.holdTimer) clearTimeout(st.holdTimer);
if (st.holdTimer) clearTimeout(st.holdTimer);
- st.holdTimer = setTimeout(() => { st.holdUntil = 0; st.holdTimer = null; pump(room); }, Math.max(0, until - Date.now()));
+ st.holdTimer = setTimeout(() => { st.holdUntil = 0; st.holdTimer = null; pump(room).catch((err) => console.warn('[AudioQueue] pump:', err.message)); }, Math.max(0, until - Date.now()));
if (st.holdTimer.unref) st.holdTimer.unref();
if (st.holdTimer.unref) st.holdTimer.unref();
}
}
@@ -180,31 +180,43 @@ function stop() {
@@ -180,31 +180,43 @@ function stop() {
* Accept a request. Returns { id, queued: true } or { queued: false, reason } — `duplicate` (this
* Accept a request. Returns { id, queued: true } or { queued: false, reason } — `duplicate` (this
* room already has this key), `full` (the room or this requester is at its limit).
* room already has this key), `full` (the room or this requester is at its limit).
*/
*/
-function enqueue({ kind, streamId = null, channelUserId = null, requestedBy = null, identityKey = null, label = null, payload = {}, dedupeKey = null, maxRoom = null, maxPerRequester = null, now = Date.now() }) {
+async function enqueue({ kind, streamId = null, channelUserId = null, requestedBy = null, identityKey = null, label = null, payload = {}, dedupeKey = null, maxRoom = null, maxPerRequester = null, now = Date.now() }) {
if (!KINDS.includes(kind)) throw new Error(`audio-queue: unknown kind ${kind}`);
if (!KINDS.includes(kind)) throw new Error(`audio-queue: unknown kind ${kind}`);
const room = roomKey({ streamId, channelUserId });
const room = roomKey({ streamId, channelUserId });
if (!room) return { queued: false, reason: 'no_room' };
if (!room) return { queued: false, reason: 'no_room' };
- const result = db.transaction(() => {
- if (dedupeKey && db.get('SELECT 1 FROM audio_requests WHERE room = ? AND dedupe_key = ?', [room, dedupeKey])) return { queued: false, reason: 'duplicate' };
+ // One transaction: the caps are counted in it, and the unique (room, dedupe_key) index decides a duplicate
+ // (ON CONFLICT DO NOTHING: no row back), so two processes enqueueing the same key store one row.
+ const result = await db.tx(async () => {
+ if (dedupeKey && await db.get('SELECT 1 FROM audio_requests WHERE room = ? AND dedupe_key = ?', [room, dedupeKey])) return { queued: false, reason: 'duplicate' };
if (maxRoom) {
if (maxRoom) {
- const n = db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing')", [room]).n;
+ const n = (await db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing')", [room])).n;
if (n >= maxRoom) return { queued: false, reason: 'full' };
if (n >= maxRoom) return { queued: false, reason: 'full' };
}
}
if (maxPerRequester && identityKey) {
if (maxPerRequester && identityKey) {
- const n = db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND identity_key = ? AND state IN ('queued', 'playing')", [room, identityKey]).n;
+ const n = (await db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND identity_key = ? AND state IN ('queued', 'playing')", [room, identityKey])).n;
if (n >= maxPerRequester) return { queued: false, reason: 'full' };
if (n >= maxPerRequester) return { queued: false, reason: 'full' };
}
}
- const r = db.run(
+ const r = await db.run(
`INSERT INTO audio_requests (room, stream_id, channel_user_id, kind, state, requested_by, identity_key, label, payload, dedupe_key, created_at)
`INSERT INTO audio_requests (room, stream_id, channel_user_id, kind, state, requested_by, identity_key, label, payload, dedupe_key, created_at)
- VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)`,
+ VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (room, dedupe_key) DO NOTHING`,
[room, streamId || null, channelUserId || null, kind, requestedBy, identityKey, label ? String(label).slice(0, 300) : null, JSON.stringify(payload || {}), dedupeKey, now],
[room, streamId || null, channelUserId || null, kind, requestedBy, identityKey, label ? String(label).slice(0, 300) : null, JSON.stringify(payload || {}), dedupeKey, now],
);
);
+ if (!r.changes) return { queued: false, reason: 'duplicate' };
return { queued: true, id: Number(r.lastInsertRowid), room };
return { queued: true, id: Number(r.lastInsertRowid), room };
});
});
- if (result.queued) setImmediate(() => pump(result.room));
+ if (result.queued) setImmediate(() => { pump(result.room).catch((err) => console.warn('[AudioQueue] pump:', err.message)); });
return result;
return result;
}
}
+/**
+ * Take a queued request to play (plan T3 decision 3): one atomic UPDATE, so of two pumps (two processes) at most
+ * one gets the row back; null = someone else took it, or it was skipped. Counts the attempt.
+ */
+async function claim(id) {
+ return await db.get("UPDATE audio_requests SET state = 'playing', attempts = attempts + 1 WHERE id = ? AND state = 'queued' RETURNING *", [id]) || null;
+}
+
/** Play the next request of a room if none is playing. */
/** Play the next request of a room if none is playing. */
async function pump(room) {
async function pump(room) {
if (stopped || !room) return;
if (stopped || !room) return;
@@ -215,10 +227,14 @@ async function pump(room) {
@@ -215,10 +227,14 @@ async function pump(room) {
for (;;) {
for (;;) {
if (stopped) return;
if (stopped) return;
let row;
let row;
- try { row = db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1", [room]); } catch { return; }
+ try { row = await db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1", [room]); } catch { return; }
if (!row) return;
if (!row) return;
- if (row.attempts >= MAX_ATTEMPTS) { transition(row.id, 'failed', { error: 'gave up after repeated attempts', actor: 'system' }); continue; }
- db.run('UPDATE audio_requests SET attempts = attempts + 1 WHERE id = ?', [row.id]);
+ if (row.attempts >= MAX_ATTEMPTS) { await transition(row.id, 'failed', { error: 'gave up after repeated attempts', actor: 'system' }); continue; }
+ const claimed = await claim(row.id);
+ if (!claimed) continue; // another pump took it (or it was skipped): the next one
+ row = claimed;
+ // The audio is made outside any transaction (a synth takes seconds); the row stays 'playing' meanwhile.
+ st.makingId = row.id;
const perform = performers[row.kind];
const perform = performers[row.kind];
let made = null, error = null;
let made = null, error = null;
try {
try {
@@ -226,14 +242,17 @@ async function pump(room) {
@@ -226,14 +242,17 @@ async function pump(room) {
if (!perform) error = `no performer for ${row.kind}`;
if (!perform) error = `no performer for ${row.kind}`;
else if (!made || !made.frame) error = 'no audio';
else if (!made || !made.frame) error = 'no audio';
} catch (err) { error = (err && err.message) || 'failed'; }
} catch (err) { error = (err && err.message) || 'failed'; }
+ st.makingId = null;
if (stopped) return;
if (stopped) return;
- if (error) { transition(row.id, 'failed', { error: String(error).slice(0, 300), actor: 'system' }); continue; }
+ if (error) { await transition(row.id, 'failed', { error: String(error).slice(0, 300), actor: 'system' }); continue; }
const durationMs = Math.round(Math.min(MAX_PLAY_MS, Math.max(MIN_PLAY_MS, Number(made.durationMs) || estimatePlayMs(made.frame))));
const durationMs = Math.round(Math.min(MAX_PLAY_MS, Math.max(MIN_PLAY_MS, Number(made.durationMs) || estimatePlayMs(made.frame))));
// Skipped or cleared while it was being made: nothing goes out.
// Skipped or cleared while it was being made: nothing goes out.
- if (!transition(row.id, 'playing', { durationMs })) continue;
+ const now = Date.now();
+ const started = await db.run("UPDATE audio_requests SET started_at = ?, duration_ms = ? WHERE id = ? AND state = 'playing'", [now, durationMs, row.id]);
+ if (!started.changes) continue;
try { if (deliver) deliver(row, { ...made.frame, request_id: row.id }); } catch (err) { console.warn('[AudioQueue] deliver:', err.message); }
try { if (deliver) deliver(row, { ...made.frame, request_id: row.id }); } catch (err) { console.warn('[AudioQueue] deliver:', err.message); }
st.playingId = row.id;
st.playingId = row.id;
- st.timer = setTimeout(() => finish(room, row.id, 'played'), durationMs + GAP_MS);
+ st.timer = setTimeout(() => { finish(room, row.id, 'played').catch((err) => console.warn('[AudioQueue] finish:', err.message)); }, durationMs + GAP_MS);
if (st.timer.unref) st.timer.unref();
if (st.timer.unref) st.timer.unref();
return;
return;
}
}
@@ -241,15 +260,15 @@ async function pump(room) {
@@ -241,15 +260,15 @@ async function pump(room) {
}
}
/** End the playing request of a room (its window ended, it was skipped, or the client reported). */
/** End the playing request of a room (its window ended, it was skipped, or the client reported). */
-function finish(room, id, to, opts = {}) {
+async function finish(room, id, to, opts = {}) {
const st = roomState(room);
const st = roomState(room);
let moved = false;
let moved = false;
- try { moved = transition(id, to, opts); } catch { moved = false; }
+ try { moved = await transition(id, to, opts); } catch { moved = false; }
if (st.playingId === id) {
if (st.playingId === id) {
if (st.timer) clearTimeout(st.timer);
if (st.timer) clearTimeout(st.timer);
st.timer = null;
st.timer = null;
st.playingId = null;
st.playingId = null;
- setImmediate(() => pump(room));
+ setImmediate(() => { pump(room).catch((err) => console.warn('[AudioQueue] pump:', err.message)); });
}
}
return moved;
return moved;
}
}
@@ -258,25 +277,27 @@ function finish(room, id, to, opts = {}) {
@@ -258,25 +277,27 @@ function finish(room, id, to, opts = {}) {
* Skip one request of a room: `id`, or else the one playing, or else the next queued.
* Skip one request of a room: `id`, or else the one playing, or else the next queued.
* Returns the skipped row or null.
* Returns the skipped row or null.
*/
*/
-function skip(room, { id = null, actor = null } = {}) {
+async function skip(room, { id = null, actor = null } = {}) {
let row = null;
let row = null;
- if (id) row = db.get("SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state IN ('queued', 'playing')", [id, room]);
- else row = db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1", [room])
- || db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1", [room]);
+ if (id) row = await db.get("SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state IN ('queued', 'playing')", [id, room]);
+ else row = await db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1", [room])
+ || await db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1", [room]);
if (!row) return null;
if (!row) return null;
- const moved = row.state === 'playing' ? finish(room, row.id, 'skipped', { actor }) : transition(row.id, 'skipped', { actor });
+ // 'playing' and out (the room's clip) is finished; 'playing' still being made never went out: no skip frame.
+ const out = row.state === 'playing' && roomState(room).playingId === row.id;
+ const moved = out ? await finish(room, row.id, 'skipped', { actor }) : await transition(row.id, 'skipped', { actor });
if (!moved) return null;
if (!moved) return null;
- if (row.state === 'playing' && deliver) { try { deliver(row, { type: 'audio-skip', request_id: row.id, kind: row.kind }); } catch { /* */ } }
+ if (out && deliver) { try { deliver(row, { type: 'audio-skip', request_id: row.id, kind: row.kind }); } catch { /* */ } }
return { ...row, state: 'skipped' };
return { ...row, state: 'skipped' };
}
}
/** Skip everything queued or playing in a room. Returns the ids skipped. */
/** Skip everything queued or playing in a room. Returns the ids skipped. */
-function clear(room, { actor = null } = {}) {
- const rows = db.all("SELECT * FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing') ORDER BY id", [room]);
+async function clear(room, { actor = null } = {}) {
+ const rows = await db.all("SELECT * FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing') ORDER BY id", [room]);
const ids = [];
const ids = [];
let sample = null;
let sample = null;
for (const row of rows) {
for (const row of rows) {
- const moved = row.state === 'playing' ? finish(room, row.id, 'skipped', { actor }) : transition(row.id, 'skipped', { actor });
+ const moved = row.state === 'playing' ? await finish(room, row.id, 'skipped', { actor }) : await transition(row.id, 'skipped', { actor });
if (moved) { ids.push(row.id); sample = sample || row; }
if (moved) { ids.push(row.id); sample = sample || row; }
}
}
if (ids.length && deliver) { try { deliver(sample, { type: 'audio-clear', request_ids: ids }); } catch { /* */ } }
if (ids.length && deliver) { try { deliver(sample, { type: 'audio-clear', request_ids: ids }); } catch { /* */ } }
@@ -284,23 +305,23 @@ function clear(room, { actor = null } = {}) {
@@ -284,23 +305,23 @@ function clear(room, { actor = null } = {}) {
}
}
/** The playing client's report: its clip ended ('played') or could not play ('failed'). */
/** The playing client's report: its clip ended ('played') or could not play ('failed'). */
-function report(room, id, state, { error = null, actor = null } = {}) {
+async function report(room, id, state, { error = null, actor = null } = {}) {
if (state !== 'played' && state !== 'failed') return false;
if (state !== 'played' && state !== 'failed') return false;
- const row = db.get("SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state = 'playing'", [id, room]);
+ const row = await db.get("SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state = 'playing'", [id, room]);
if (!row) return false;
if (!row) return false;
- return finish(room, row.id, state, { error: state === 'failed' ? String(error || 'playback failed').slice(0, 300) : null, actor });
+ return await finish(room, row.id, state, { error: state === 'failed' ? String(error || 'playback failed').slice(0, 300) : null, actor });
}
}
/** A room's queue: what plays now, what waits, and the latest finished requests. */
/** A room's queue: what plays now, what waits, and the latest finished requests. */
-function list(room, { recent = 20 } = {}) {
+async function list(room, { recent = 20 } = {}) {
const shape = (r) => ({
const shape = (r) => ({
id: r.id, kind: r.kind, state: r.state, requested_by: r.requested_by, label: r.label,
id: r.id, kind: r.kind, state: r.state, requested_by: r.requested_by, label: r.label,
error: r.error, actor: r.actor, duration_ms: r.duration_ms,
error: r.error, actor: r.actor, duration_ms: r.duration_ms,
created_at: r.created_at, started_at: r.started_at, finished_at: r.finished_at,
created_at: r.created_at, started_at: r.started_at, finished_at: r.finished_at,
});
});
- const playing = db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1", [room]);
- const queued = db.all("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id", [room]);
- const done = db.all("SELECT * FROM audio_requests WHERE room = ? AND state IN ('played', 'skipped', 'failed') ORDER BY COALESCE(finished_at, created_at) DESC, id DESC LIMIT ?", [room, Math.min(Math.max(parseInt(recent, 10) || 0, 0), 100)]);
+ const playing = await db.get("SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1", [room]);
+ const queued = await db.all("SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id", [room]);
+ const done = await db.all("SELECT * FROM audio_requests WHERE room = ? AND state IN ('played', 'skipped', 'failed') ORDER BY COALESCE(finished_at, created_at) DESC, id DESC LIMIT ?", [room, Math.min(Math.max(parseInt(recent, 10) || 0, 0), 100)]);
return { room, playing: playing ? shape(playing) : null, queued: queued.map(shape), recent: done.map(shape) };
return { room, playing: playing ? shape(playing) : null, queued: queued.map(shape), recent: done.map(shape) };
}
}
@@ -308,11 +329,11 @@ function list(room, { recent = 20 } = {}) {
@@ -308,11 +329,11 @@ function list(room, { recent = 20 } = {}) {
* Boot: rows playing when Chat stopped were delivered, so they are played; rows queued too long
* Boot: rows playing when Chat stopped were delivered, so they are played; rows queued too long
* ago fail as expired; every room with queued rows starts playing again.
* ago fail as expired; every room with queued rows starts playing again.
*/
*/
-function recover({ now = Date.now(), graceMs = RESUME_GRACE_MS } = {}) {
- const played = db.run("UPDATE audio_requests SET state = 'played', finished_at = ?, error = 'delivered before a restart' WHERE state = 'playing'", [now]).changes;
- const expired = db.run("UPDATE audio_requests SET state = 'failed', finished_at = ?, error = 'expired', actor = 'system' WHERE state = 'queued' AND created_at < ?", [now, now - STALE_MS]).changes;
- db.run("DELETE FROM audio_requests WHERE state IN ('played', 'skipped', 'failed') AND COALESCE(finished_at, created_at) < ?", [now - KEEP_MS]);
- const pending = db.all("SELECT DISTINCT room FROM audio_requests WHERE state = 'queued'").map((r) => r.room);
+async function recover({ now = Date.now(), graceMs = RESUME_GRACE_MS } = {}) {
+ const played = (await db.run("UPDATE audio_requests SET state = 'played', finished_at = ?, error = 'delivered before a restart' WHERE state = 'playing'", [now])).changes;
+ const expired = (await db.run("UPDATE audio_requests SET state = 'failed', finished_at = ?, error = 'expired', actor = 'system' WHERE state = 'queued' AND created_at < ?", [now, now - STALE_MS])).changes;
+ await db.run("DELETE FROM audio_requests WHERE state IN ('played', 'skipped', 'failed') AND COALESCE(finished_at, created_at) < ?", [now - KEEP_MS]);
+ const pending = (await db.all("SELECT DISTINCT room FROM audio_requests WHERE state = 'queued'")).map((r) => r.room);
for (const room of pending) holdRoom(room, now + graceMs);
for (const room of pending) holdRoom(room, now + graceMs);
if (played || expired || pending.length) console.log(`[AudioQueue] recovered: ${played} finished, ${expired} expired, ${pending.length} room(s) resuming`);
if (played || expired || pending.length) console.log(`[AudioQueue] recovered: ${played} finished, ${expired} expired, ${pending.length} room(s) resuming`);
return { played, expired, rooms: pending };
return { played, expired, rooms: pending };
@@ -321,5 +342,5 @@ function recover({ now = Date.now(), graceMs = RESUME_GRACE_MS } = {}) {
@@ -321,5 +342,5 @@ function recover({ now = Date.now(), graceMs = RESUME_GRACE_MS } = {}) {
module.exports = {
module.exports = {
STATES, TRANSITIONS, KINDS, STALE_MS, GAP_MS, RESUME_GRACE_MS, RESUME_SETTLE_MS,
STATES, TRANSITIONS, KINDS, STALE_MS, GAP_MS, RESUME_GRACE_MS, RESUME_SETTLE_MS,
canTransition, roomKey, estimatePlayMs, mp3Bitrate,
canTransition, roomKey, estimatePlayMs, mp3Bitrate,
- init, stop, enqueue, pump, skip, clear, report, list, recover, roomJoined, getRequest, transition,
+ init, stop, enqueue, pump, claim, skip, clear, report, list, recover, roomJoined, getRequest, transition,
};
};
@@ -41,12 +41,12 @@ function canAssignMods(user, channel) {
@@ -41,12 +41,12 @@ function canAssignMods(user, channel) {
}
}
/** Middleware: require channel access (owner, channel mod, or staff). */
/** Middleware: require channel access (owner, channel mod, or staff). */
-function requireChannelAccess(req, res, next) {
+async function requireChannelAccess(req, res, next) {
const channelId = parseInt(req.params.channelId, 10);
const channelId = parseInt(req.params.channelId, 10);
if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });
if (!channelId) return res.status(400).json({ error: 'Invalid channel ID' });
- const channel = ctx.getChannelById(channelId);
+ const channel = await ctx.getChannelById(channelId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
- if (!permissions.canModerateChannel(req.user, channelId)) {
+ if (!await permissions.canModerateChannel(req.user, channelId)) {
return res.status(403).json({ error: 'Channel moderation access required' });
return res.status(403).json({ error: 'Channel moderation access required' });
}
}
req.channel = channel;
req.channel = channel;
@@ -57,7 +57,7 @@ function requireChannelAccess(req, res, next) {
@@ -57,7 +57,7 @@ function requireChannelAccess(req, res, next) {
router.get('/moderation/mine', requireAuth, async (req, res) => {
router.get('/moderation/mine', requireAuth, async (req, res) => {
try {
try {
const owned = await ctx.ensureChannelForUser(req.user.id);
const owned = await ctx.ensureChannelForUser(req.user.id);
- const moderated = db.getChannelsByModerator(req.user.id) || [];
+ const moderated = await db.getChannelsByModerator(req.user.id) || [];
const channelMap = new Map();
const channelMap = new Map();
if (owned) channelMap.set(owned.id, owned);
if (owned) channelMap.set(owned.id, owned);
@@ -66,11 +66,11 @@ router.get('/moderation/mine', requireAuth, async (req, res) => {
@@ -66,11 +66,11 @@ router.get('/moderation/mine', requireAuth, async (req, res) => {
if (!channelMap.has(id)) channelMap.set(id, { id, user_id: ch.owner_user_id ?? ch.user_id, title: ch.title });
if (!channelMap.has(id)) channelMap.set(id, { id, user_id: ch.owner_user_id ?? ch.user_id, title: ch.title });
}
}
- const channels = [...channelMap.values()].map((channel) => ({
+ const channels = (await Promise.all([...channelMap.values()].map(async (channel) => ({
...channel,
...channel,
- moderation_settings: ctx.getChannelModerationSettings(channel.id),
- moderators: db.getChannelModerators(channel.id),
- }));
+ moderation_settings: await ctx.getChannelModerationSettings(channel.id),
+ moderators: await db.getChannelModerators(channel.id),
+ }))));
res.json({ channels });
res.json({ channels });
} catch (err) {
} catch (err) {
@@ -80,12 +80,12 @@ router.get('/moderation/mine', requireAuth, async (req, res) => {
@@ -80,12 +80,12 @@ router.get('/moderation/mine', requireAuth, async (req, res) => {
});
});
// ── List channel mods ────────────────────────────────────────
// ── List channel mods ────────────────────────────────────────
-router.get('/:channelId/mods', requireAuth, (req, res) => {
+router.get('/:channelId/mods', requireAuth, async (req, res) => {
try {
try {
const channelId = parseInt(req.params.channelId);
const channelId = parseInt(req.params.channelId);
- const channel = ctx.getChannelById(channelId);
+ const channel = await ctx.getChannelById(channelId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
- res.json({ moderators: db.getChannelModerators(channelId), channel_id: channelId });
+ res.json({ moderators: await db.getChannelModerators(channelId), channel_id: channelId });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to list channel moderators' });
res.status(500).json({ error: 'Failed to list channel moderators' });
}
}
@@ -95,7 +95,7 @@ router.get('/:channelId/mods', requireAuth, (req, res) => {
@@ -95,7 +95,7 @@ router.get('/:channelId/mods', requireAuth, (req, res) => {
router.post('/:channelId/mods', requireAuth, async (req, res) => {
router.post('/:channelId/mods', requireAuth, async (req, res) => {
try {
try {
const channelId = parseInt(req.params.channelId);
const channelId = parseInt(req.params.channelId);
- const channel = ctx.getChannelById(channelId);
+ const channel = await ctx.getChannelById(channelId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!canAssignMods(req.user, channel)) {
if (!canAssignMods(req.user, channel)) {
return res.status(403).json({ error: 'Only the channel owner or an admin can add moderators' });
return res.status(403).json({ error: 'Only the channel owner or an admin can add moderators' });
@@ -109,8 +109,8 @@ router.post('/:channelId/mods', requireAuth, async (req, res) => {
@@ -109,8 +109,8 @@ router.post('/:channelId/mods', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Channel owner is already a moderator' });
return res.status(400).json({ error: 'Channel owner is already a moderator' });
}
}
- db.addChannelModerator(channelId, targetUser.id, req.user.id);
- db.logModerationAction({
+ await db.addChannelModerator(channelId, targetUser.id, req.user.id);
+ await db.logModerationAction({
scope_type: 'channel',
scope_type: 'channel',
scope_id: channelId,
scope_id: channelId,
actor_user_id: req.user.id,
actor_user_id: req.user.id,
@@ -118,7 +118,7 @@ router.post('/:channelId/mods', requireAuth, async (req, res) => {
@@ -118,7 +118,7 @@ router.post('/:channelId/mods', requireAuth, async (req, res) => {
action_type: 'channel_mod_add',
action_type: 'channel_mod_add',
details: { channel_id: channelId, username: targetUser.username },
details: { channel_id: channelId, username: targetUser.username },
});
});
- res.json({ message: `${targetUser.username} added as channel moderator`, moderators: db.getChannelModerators(channelId) });
+ res.json({ message: `${targetUser.username} added as channel moderator`, moderators: await db.getChannelModerators(channelId) });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to add channel moderator' });
res.status(500).json({ error: 'Failed to add channel moderator' });
}
}
@@ -129,14 +129,14 @@ router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {
@@ -129,14 +129,14 @@ router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {
try {
try {
const channelId = parseInt(req.params.channelId);
const channelId = parseInt(req.params.channelId);
const userId = parseInt(req.params.userId);
const userId = parseInt(req.params.userId);
- const channel = ctx.getChannelById(channelId);
+ const channel = await ctx.getChannelById(channelId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!canAssignMods(req.user, channel)) {
if (!canAssignMods(req.user, channel)) {
return res.status(403).json({ error: 'Only the channel owner or an admin can remove moderators' });
return res.status(403).json({ error: 'Only the channel owner or an admin can remove moderators' });
}
}
- const target = ctx.getUserById(userId);
- db.removeChannelModerator(channelId, userId);
- db.logModerationAction({
+ const target = await ctx.getUserById(userId);
+ await db.removeChannelModerator(channelId, userId);
+ await db.logModerationAction({
scope_type: 'channel',
scope_type: 'channel',
scope_id: channelId,
scope_id: channelId,
actor_user_id: req.user.id,
actor_user_id: req.user.id,
@@ -144,16 +144,16 @@ router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {
@@ -144,16 +144,16 @@ router.delete('/:channelId/mods/:userId', requireAuth, async (req, res) => {
action_type: 'channel_mod_remove',
action_type: 'channel_mod_remove',
details: { channel_id: channelId, username: (target && target.username) || String(userId) },
details: { channel_id: channelId, username: (target && target.username) || String(userId) },
});
});
- res.json({ message: 'Channel moderator removed', moderators: db.getChannelModerators(channelId) });
+ res.json({ message: 'Channel moderator removed', moderators: await db.getChannelModerators(channelId) });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to remove channel moderator' });
res.status(500).json({ error: 'Failed to remove channel moderator' });
}
}
});
});
// ── Get channel moderation settings ──────────────────────────
// ── Get channel moderation settings ──────────────────────────
-router.get('/:channelId/moderation', requireAuth, requireChannelAccess, (req, res) => {
+router.get('/:channelId/moderation', requireAuth, requireChannelAccess, async (req, res) => {
try {
try {
- res.json({ settings: ctx.getChannelModerationSettings(req.channel.id) });
+ res.json({ settings: await ctx.getChannelModerationSettings(req.channel.id) });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to get moderation settings' });
res.status(500).json({ error: 'Failed to get moderation settings' });
}
}
@@ -165,7 +165,7 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
@@ -165,7 +165,7 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
try {
try {
const isOwnerOrStaff = req.channel.user_id === req.user.id || permissions.isGlobalModOrAbove(req.user);
const isOwnerOrStaff = req.channel.user_id === req.user.id || permissions.isGlobalModOrAbove(req.user);
if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];
if (!isOwnerOrStaff) for (const k of OWNER_POLICY_KEYS) delete req.body[k];
- const settings = db.upsertChannelModerationSettings(req.channel.id, {
+ const settings = await db.upsertChannelModerationSettings(req.channel.id, {
slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined
slow_mode_seconds: (req.body.slow_mode_seconds ?? req.body.slowmode_seconds) !== undefined
? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,
? Math.max(0, parseInt(req.body.slow_mode_seconds ?? req.body.slowmode_seconds) || 0) : undefined,
followers_only: req.body.followers_only !== undefined ? parseBoolean(req.body.followers_only, false) : undefined,
followers_only: req.body.followers_only !== undefined ? parseBoolean(req.body.followers_only, false) : undefined,
@@ -225,7 +225,7 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
@@ -225,7 +225,7 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
sound_max_pitch_cents: req.body.sound_max_pitch_cents !== undefined ? Math.max(0, Math.min(2400, parseInt(req.body.sound_max_pitch_cents) || 1200)) : undefined,
sound_max_pitch_cents: req.body.sound_max_pitch_cents !== undefined ? Math.max(0, Math.min(2400, parseInt(req.body.sound_max_pitch_cents) || 1200)) : undefined,
});
});
- db.logModerationAction({
+ await db.logModerationAction({
scope_type: 'channel',
scope_type: 'channel',
scope_id: req.channel.id,
scope_id: req.channel.id,
actor_user_id: req.user.id,
actor_user_id: req.user.id,
@@ -240,40 +240,40 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
@@ -240,40 +240,40 @@ router.put('/:channelId/moderation', requireAuth, requireChannelAccess, async (r
});
});
// ── View moderation action log ───────────────────────────────
// ── View moderation action log ───────────────────────────────
-router.get('/:channelId/moderation/logs', requireAuth, requireChannelAccess, (req, res) => {
+router.get('/:channelId/moderation/logs', requireAuth, requireChannelAccess, async (req, res) => {
try {
try {
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
- res.json({ actions: db.getModerationActions({ scopeType: 'channel', scopeId: req.channel.id, limit, offset }) });
+ res.json({ actions: await db.getModerationActions({ scopeType: 'channel', scopeId: req.channel.id, limit, offset }) });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to load channel moderation log' });
res.status(500).json({ error: 'Failed to load channel moderation log' });
}
}
});
});
// ── Search channel chat messages ─────────────────────────────
// ── Search channel chat messages ─────────────────────────────
-router.get('/:channelId/moderation/chat-search', requireAuth, requireChannelAccess, (req, res) => {
+router.get('/:channelId/moderation/chat-search', requireAuth, requireChannelAccess, async (req, res) => {
try {
try {
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
const q = (req.query.q || '').trim();
const q = (req.query.q || '').trim();
const userId = req.query.user_id ? parseInt(req.query.user_id, 10) : null;
const userId = req.query.user_id ? parseInt(req.query.user_id, 10) : null;
- res.json(db.searchChannelChatMessages(req.channel, { query: q, userId, limit, offset }));
+ res.json(await db.searchChannelChatMessages(req.channel, { query: q, userId, limit, offset }));
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to search channel chat' });
res.status(500).json({ error: 'Failed to search channel chat' });
}
}
});
});
// ── Delete a channel chat message ────────────────────────────
// ── Delete a channel chat message ────────────────────────────
-router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, requireChannelAccess, (req, res) => {
+router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, requireChannelAccess, async (req, res) => {
try {
try {
const messageId = parseInt(req.params.messageId, 10);
const messageId = parseInt(req.params.messageId, 10);
- const message = db.getChatMessageById(messageId);
+ const message = await db.getChatMessageById(messageId);
if (!message) return res.status(404).json({ error: 'Message not found' });
if (!message) return res.status(404).json({ error: 'Message not found' });
if (!permissions.isGlobalModOrAbove(req.user)) {
if (!permissions.isGlobalModOrAbove(req.user)) {
let inScope = false;
let inScope = false;
if (message.stream_id) {
if (message.stream_id) {
- const stream = ctx.getStreamById(message.stream_id);
+ const stream = await ctx.getStreamById(message.stream_id);
inScope = !!(stream && (stream.channel_id === req.channel.id || (!stream.channel_id && stream.user_id === req.channel.user_id)));
inScope = !!(stream && (stream.channel_id === req.channel.id || (!stream.channel_id && stream.user_id === req.channel.user_id)));
} else if (message.channel_user_id) {
} else if (message.channel_user_id) {
inScope = message.channel_user_id === req.channel.user_id;
inScope = message.channel_user_id === req.channel.user_id;
@@ -281,19 +281,19 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, re
@@ -281,19 +281,19 @@ router.post('/:channelId/moderation/messages/:messageId/delete', requireAuth, re
if (!inScope) return res.status(403).json({ error: 'Message is outside this channel scope' });
if (!inScope) return res.status(403).json({ error: 'Message is outside this channel scope' });
}
}
- db.deleteChatMessage(messageId, req.user.id);
+ await db.deleteChatMessage(messageId, req.user.id);
const delPayload = { type: 'delete-messages', ids: [messageId] };
const delPayload = { type: 'delete-messages', ids: [messageId] };
- const chanUid = message.channel_user_id || (message.stream_id ? (ctx.getStreamById(message.stream_id) || {}).user_id || null : null);
+ const chanUid = message.channel_user_id || (message.stream_id ? (await ctx.getStreamById(message.stream_id) || {}).user_id || null : null);
try {
try {
const chatServer = require('./chat-server');
const chatServer = require('./chat-server');
- if (chanUid || message.stream_id) chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);
- if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);
- else if (chanUid) chatServer.forwardToGlobalByChannel(chanUid, delPayload);
- else chatServer.broadcastGlobal(delPayload);
+ if (chanUid || message.stream_id) await chatServer.broadcastToChannelRoom(chanUid, message.stream_id || null, delPayload);
+ if (message.stream_id) await chatServer.forwardToGlobal(message.stream_id, delPayload);
+ else if (chanUid) await chatServer.forwardToGlobalByChannel(chanUid, delPayload);
+ else await chatServer.broadcastGlobal(delPayload);
} catch { /* chat server may not be initialized */ }
} catch { /* chat server may not be initialized */ }
- db.logModerationAction({
+ await db.logModerationAction({
scope_type: 'channel',
scope_type: 'channel',
scope_id: req.channel.id,
scope_id: req.channel.id,
actor_user_id: req.user.id,
actor_user_id: req.user.id,
@@ -91,7 +91,7 @@ class ChatServer {
@@ -91,7 +91,7 @@ class ChatServer {
this._modeWrites = new Map(); // channelId → /slow or /subonly writes running
this._modeWrites = new Map(); // channelId → /slow or /subonly writes running
/** @type {WeakMap<object, Set<string>|null>} frame → the subjects who blocked its author (_blockersOf) */
/** @type {WeakMap<object, Set<string>|null>} frame → the subjects who blocked its author (_blockersOf) */
this._blockMemo = new WeakMap();
this._blockMemo = new WeakMap();
- ctx.onChannelSettings((channelId, settings) => this._channelSettingsSeen(channelId, settings));
+ ctx.onChannelSettings(async (channelId, settings) => await this._channelSettingsSeen(channelId, settings));
this.heartbeatInterval = null;
this.heartbeatInterval = null;
// TTS and sound requests queue in Chat's database (./audio-queue.js), one room at a time.
// TTS and sound requests queue in Chat's database (./audio-queue.js), one room at a time.
/** @type {Map<string, number>} `${streamId}:${userKey}` → last soundboard trigger */
/** @type {Map<string, number>} `${streamId}:${userKey}` → last soundboard trigger */
@@ -180,7 +180,7 @@ class ChatServer {
@@ -180,7 +180,7 @@ class ChatServer {
/**
/**
* Attach to an existing HTTP server for WebSocket upgrade
* Attach to an existing HTTP server for WebSocket upgrade
*/
*/
- init(server) {
+ async init(server) {
this.wss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
this.wss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
// Word filter
// Word filter
@@ -209,15 +209,15 @@ class ChatServer {
@@ -209,15 +209,15 @@ class ChatServer {
// ── Viewer snapshot recording (every 60s) ────────────
// ── Viewer snapshot recording (every 60s) ────────────
if (this._snapshotInterval) clearInterval(this._snapshotInterval);
if (this._snapshotInterval) clearInterval(this._snapshotInterval);
- this._snapshotInterval = setInterval(() => {
- this._recordViewerSnapshots();
+ this._snapshotInterval = setInterval(async () => {
+ await this._recordViewerSnapshots();
}, 60_000);
}, 60_000);
if (this._autoDeleteSweepInterval) clearInterval(this._autoDeleteSweepInterval);
if (this._autoDeleteSweepInterval) clearInterval(this._autoDeleteSweepInterval);
- this._autoDeleteSweepInterval = setInterval(() => {
- this._sweepExpiredChatMessages();
+ this._autoDeleteSweepInterval = setInterval(async () => {
+ await this._sweepExpiredChatMessages();
}, CHAT_AUTO_DELETE_SWEEP_MS);
}, CHAT_AUTO_DELETE_SWEEP_MS);
- this._sweepExpiredChatMessages();
+ await this._sweepExpiredChatMessages();
this.wss.on('connection', (ws, req) => {
this.wss.on('connection', (ws, req) => {
this.handleConnection(ws, req);
this.handleConnection(ws, req);
@@ -227,13 +227,13 @@ class ChatServer {
@@ -227,13 +227,13 @@ class ChatServer {
// where its frame goes. recover() resumes what a restart interrupted.
// where its frame goes. recover() resumes what a restart interrupted.
audioQueue.init({
audioQueue.init({
performers: {
performers: {
- tts: (row, p) => this._makeTtsAudio(p),
- 'channel-sound': (row, p) => this._makeChannelSoundAudio(p),
- soundboard: (row, p) => this._makeSoundboardAudio(p),
+ tts: async (row, p) => await this._makeTtsAudio(p),
+ 'channel-sound': async (row, p) => await this._makeChannelSoundAudio(p),
+ soundboard: async (row, p) => await this._makeSoundboardAudio(p),
},
},
- deliver: (row, frame) => this._broadcastTtsPayload(row.stream_id, row.channel_user_id, frame),
+ deliver: async (row, frame) => await this._broadcastTtsPayload(row.stream_id, row.channel_user_id, frame),
});
});
- try { audioQueue.recover(); } catch (err) { console.warn('[AudioQueue] recover failed:', err.message); }
+ try { await audioQueue.recover(); } catch (err) { console.warn('[AudioQueue] recover failed:', err.message); }
console.log('[Chat] WebSocket chat server initialized');
console.log('[Chat] WebSocket chat server initialized');
return this.wss;
return this.wss;
@@ -358,7 +358,7 @@ class ChatServer {
@@ -358,7 +358,7 @@ class ChatServer {
console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
return;
return;
}
}
- chain = chain.then(() => this.handleMessage(ws, msg)).catch((err) => {
+ chain = chain.then(async () => await this.handleMessage(ws, msg)).catch((err) => {
console.warn('[Chat] message handling failed:', err.message);
console.warn('[Chat] message handling failed:', err.message);
});
});
};
};
@@ -393,11 +393,11 @@ class ChatServer {
@@ -393,11 +393,11 @@ class ChatServer {
const lastMsg = this.rateLimits.get(rateKey) || 0;
const lastMsg = this.rateLimits.get(rateKey) || 0;
// Slow mode of the stream that governs the room (an offline/channel-room join has no
// Slow mode of the stream that governs the room (an offline/channel-room join has no
// stream id of its own but posts into the live room).
// stream id of its own but posts into the live room).
- const slowStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);
- const streamSlowMs = this.slowModeMs(slowStreamId);
+ const slowStreamId = client.streamId || (client.channelUserId ? await this._moderationStreamFor(client) : null);
+ const streamSlowMs = await this.slowModeMs(slowStreamId);
// The room's moderators (broadcaster, channel mods, chat staff) are not slowed: they keep
// The room's moderators (broadcaster, channel mods, chat staff) are not slowed: they keep
// the flood limit only, so they can moderate and turn slow mode off again at once.
// the flood limit only, so they can moderate and turn slow mode off again at once.
- const slowExempt = streamSlowMs > 0 && !!client.user && permissions.canModerateStream(client.user, slowStreamId);
+ const slowExempt = streamSlowMs > 0 && !!client.user && await permissions.canModerateStream(client.user, slowStreamId);
const effectiveLimit = slowExempt ? this.DEFAULT_RATE_LIMIT_MS : Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);
const effectiveLimit = slowExempt ? this.DEFAULT_RATE_LIMIT_MS : Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);
if (now - lastMsg < effectiveLimit) {
if (now - lastMsg < effectiveLimit) {
this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
@@ -409,10 +409,10 @@ class ChatServer {
@@ -409,10 +409,10 @@ class ChatServer {
switch (msg.type) {
switch (msg.type) {
case 'chat':
case 'chat':
await this._warmSubOnly(client).catch(() => {});
await this._warmSubOnly(client).catch(() => {});
- this.handleChatMessage(ws, client, msg);
+ await this.handleChatMessage(ws, client, msg);
break;
break;
case 'self-delete-history':
case 'self-delete-history':
- this.handleSelfDeleteHistory(ws, client);
+ await this.handleSelfDeleteHistory(ws, client);
break;
break;
case 'join':
case 'join':
case 'join_stream': {
case 'join_stream': {
@@ -422,7 +422,7 @@ class ChatServer {
@@ -422,7 +422,7 @@ class ChatServer {
if (user) {
if (user) {
if (!client.user || client.user.id === user.id) {
if (!client.user || client.user.id === user.id) {
client.user = user;
client.user = user;
- this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
+ await this._hiddenFromUserList(user); // warm chat.presence_prefs before the next user list
client.tokenIat = session.tokenIat(msg.token);
client.tokenIat = session.tokenIat(msg.token);
client.anonId = null; // no longer anonymous
client.anonId = null; // no longer anonymous
} else {
} else {
@@ -438,7 +438,7 @@ class ChatServer {
@@ -438,7 +438,7 @@ class ChatServer {
await ctx.warm({ user: client.user, streamId: nextStreamId, channelUserId: requestedChannel, ip: client.ip }).catch(() => {});
await ctx.warm({ user: client.user, streamId: nextStreamId, channelUserId: requestedChannel, ip: client.ip }).catch(() => {});
if (!nextStreamId && requestedChannel) {
if (!nextStreamId && requestedChannel) {
// Offline channel room: its moderation comes from the channel's latest stream.
// Offline channel room: its moderation comes from the channel's latest stream.
- await ctx.warm({ user: null, streamId: ctx.latestStreamIdForUser(requestedChannel), channelUserId: requestedChannel }).catch(() => {});
+ await ctx.warm({ user: null, streamId: await ctx.latestStreamIdForUser(requestedChannel), channelUserId: requestedChannel }).catch(() => {});
}
}
client.streamId = nextStreamId;
client.streamId = nextStreamId;
// Channel room: the streamer's stable user id. Lets a viewer stay in
// Channel room: the streamer's stable user id. Lets a viewer stay in
@@ -451,12 +451,12 @@ class ChatServer {
@@ -451,12 +451,12 @@ class ChatServer {
// room, where B's bans and chat rules (checked against the stream) never applied.
// room, where B's bans and chat rules (checked against the stream) never applied.
if (client.streamId) {
if (client.streamId) {
channelUserId = null;
channelUserId = null;
- try { const s = ctx.getStreamById(client.streamId); if (s) channelUserId = s.user_id; } catch { /* ignore */ }
+ try { const s = await ctx.getStreamById(client.streamId); if (s) channelUserId = s.user_id; } catch { /* ignore */ }
}
}
client.channelUserId = channelUserId;
client.channelUserId = channelUserId;
client._modStream = null;
client._modStream = null;
// Look up this member's VIP badge for the room now, so their first message has it.
// Look up this member's VIP badge for the room now, so their first message has it.
- if (client.user && channelUserId) { try { vipBadges.warm(this._subjectOfUser(client.user), db.subjectFor(channelUserId)); } catch { /* */ } }
+ if (client.user && channelUserId) { try { vipBadges.warm(await this._subjectOfUser(client.user), await db.subjectFor(channelUserId)); } catch { /* */ } }
// A TTS/sound queue held since a restart plays once its room has listeners again.
// A TTS/sound queue held since a restart plays once its room has listeners again.
audioQueue.roomJoined(audioQueue.roomKey({ streamId: client.streamId, channelUserId: client.streamId ? null : channelUserId }));
audioQueue.roomJoined(audioQueue.roomKey({ streamId: client.streamId, channelUserId: client.streamId ? null : channelUserId }));
// Update viewer counts for old and new streams
// Update viewer counts for old and new streams
@@ -468,9 +468,9 @@ class ChatServer {
@@ -468,9 +468,9 @@ class ChatServer {
setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);
setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);
// Send identity confirmation so the client knows who it is
// Send identity confirmation so the client knows who it is
const displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;
const displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;
- const roomStreamId = client.streamId || this._moderationStreamFor(client);
- const streamSlowSec = Math.round(this.slowModeMs(roomStreamId) / 1000);
- const streamSettings = this._getChannelChatSettings(client.streamId);
+ const roomStreamId = client.streamId || await this._moderationStreamFor(client);
+ const streamSlowSec = Math.round(await this.slowModeMs(roomStreamId) / 1000);
+ const streamSettings = await this._getChannelChatSettings(client.streamId);
this.sendTo(ws, {
this.sendTo(ws, {
type: 'auth',
type: 'auth',
authenticated: !!client.user,
authenticated: !!client.user,
@@ -480,7 +480,7 @@ class ChatServer {
@@ -480,7 +480,7 @@ class ChatServer {
role: client.user ? client.user.role : 'anon',
role: client.user ? client.user.role : 'anon',
user_id: client.user?.id || null,
user_id: client.user?.id || null,
slowmode_seconds: streamSlowSec,
slowmode_seconds: streamSlowSec,
- sub_only: !!(roomStreamId && this._getChannelChatSettings(roomStreamId).sub_only),
+ sub_only: !!(roomStreamId && (await this._getChannelChatSettings(roomStreamId)).sub_only),
allow_auto_delete: !client.streamId || streamSettings.viewer_auto_delete_enabled !== 0,
allow_auto_delete: !client.streamId || streamSettings.viewer_auto_delete_enabled !== 0,
allow_self_delete_all: !client.streamId || streamSettings.viewer_delete_all_enabled !== 0,
allow_self_delete_all: !client.streamId || streamSettings.viewer_delete_all_enabled !== 0,
gifs_enabled: !client.streamId || streamSettings.gifs_enabled !== 0,
gifs_enabled: !client.streamId || streamSettings.gifs_enabled !== 0,
@@ -501,7 +501,7 @@ class ChatServer {
@@ -501,7 +501,7 @@ class ChatServer {
min_auto_delete_minutes: MIN_CHAT_AUTO_DELETE_MINUTES,
min_auto_delete_minutes: MIN_CHAT_AUTO_DELETE_MINUTES,
// Language the channel lives in — the client shows "auto-translated for the
// Language the channel lives in — the client shows "auto-translated for the
// streamer" when it isn't English (see server/i18n/translate.js).
// streamer" when it isn't English (see server/i18n/translate.js).
- channel_language: this._channelLanguage(client.channelUserId),
+ channel_language: await this._channelLanguage(client.channelUserId),
});
});
break;
break;
}
}
@@ -511,8 +511,8 @@ class ChatServer {
@@ -511,8 +511,8 @@ class ChatServer {
// Chat rooms (server/rooms/, openvibe.chat): a socket follows one room at a time.
// Chat rooms (server/rooms/, openvibe.chat): a socket follows one room at a time.
case 'join_room': {
case 'join_room': {
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
- const room = rooms.bySlug(msg.room);
- const a = room ? rooms.access(room, client.user) : null;
+ const room = await rooms.bySlug(msg.room);
+ const a = room ? await rooms.access(room, client.user) : null;
if (!room || !a.read) { this.sendTo(ws, { type: 'room_error', room: msg.room || null, code: 'rooms.not_found', message: 'No such room' }); break; }
if (!room || !a.read) { this.sendTo(ws, { type: 'room_error', room: msg.room || null, code: 'rooms.not_found', message: 'No such room' }); break; }
client.roomId = room.id;
client.roomId = room.id;
client.roomSlug = room.slug;
client.roomSlug = room.slug;
@@ -525,10 +525,10 @@ class ChatServer {
@@ -525,10 +525,10 @@ class ChatServer {
break;
break;
case 'room_message': {
case 'room_message': {
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
- const room = client.roomId ? rooms.bySlug(client.roomSlug) : null;
+ const room = client.roomId ? await rooms.bySlug(client.roomSlug) : null;
if (!room || room.id !== client.roomId) { this.sendTo(ws, { type: 'room_error', code: 'rooms.not_joined', message: 'Open a room first' }); break; }
if (!room || room.id !== client.roomId) { this.sendTo(ws, { type: 'room_error', code: 'rooms.not_joined', message: 'Open a room first' }); break; }
try {
try {
- const message = rooms.post(room, client.user, msg.message);
+ const message = await rooms.post(room, client.user, msg.message);
this.broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
this.broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
} catch (err) {
} catch (err) {
this.sendTo(ws, { type: 'room_error', room: room.slug, code: err.code || 'rooms.error', message: err.code ? err.message : 'Your message could not be sent' });
this.sendTo(ws, { type: 'room_error', room: room.slug, code: err.code || 'rooms.error', message: err.code ? err.message : 'Your message could not be sent' });
@@ -536,7 +536,7 @@ class ChatServer {
@@ -536,7 +536,7 @@ class ChatServer {
break;
break;
}
}
case 'get-users':
case 'get-users':
- this.sendTo(ws, { type: 'users-list', users: this.getUserList(client.streamId) });
+ this.sendTo(ws, { type: 'users-list', users: await this.getUserList(client.streamId) });
break;
break;
default:
default:
break;
break;
@@ -544,21 +544,21 @@ class ChatServer {
@@ -544,21 +544,21 @@ class ChatServer {
}
}
/** The stream whose bans and chat settings govern an offline channel-room chatter (cached 30s). */
/** The stream whose bans and chat settings govern an offline channel-room chatter (cached 30s). */
- _moderationStreamFor(client) {
+ async _moderationStreamFor(client) {
if (!client || !client.channelUserId) return null;
if (!client || !client.channelUserId) return null;
const now = Date.now();
const now = Date.now();
if (client._modStream && now - client._modStream.at < 30000) return client._modStream.id;
if (client._modStream && now - client._modStream.at < 30000) return client._modStream.id;
let id = null;
let id = null;
- try { id = ctx.latestStreamIdForUser(client.channelUserId); } catch { id = null; }
+ try { id = await ctx.latestStreamIdForUser(client.channelUserId); } catch { id = null; }
client._modStream = { id, at: now };
client._modStream = { id, at: now };
return id;
return id;
}
}
- handleSelfDeleteHistory(ws, client) {
+ async handleSelfDeleteHistory(ws, client) {
if (!client) return;
if (!client) return;
- const canBypass = client.streamId ? permissions.canModerateStream(client.user, client.streamId) : false;
- const chatSettings = this._getChannelChatSettings(client.streamId);
+ const canBypass = client.streamId ? await permissions.canModerateStream(client.user, client.streamId) : false;
+ const chatSettings = await this._getChannelChatSettings(client.streamId);
if (client.streamId && !canBypass && chatSettings.viewer_delete_all_enabled === 0) {
if (client.streamId && !canBypass && chatSettings.viewer_delete_all_enabled === 0) {
this.sendTo(ws, { type: 'error', message: 'This streamer has disabled viewer self-delete for this chat.' });
this.sendTo(ws, { type: 'error', message: 'This streamer has disabled viewer self-delete for this chat.' });
return;
return;
@@ -566,12 +566,12 @@ class ChatServer {
@@ -566,12 +566,12 @@ class ChatServer {
let ids = [];
let ids = [];
if (client.user?.id) {
if (client.user?.id) {
- ids = db.deleteUserChatMessages(client.user.id, {
+ ids = await db.deleteUserChatMessages(client.user.id, {
streamId: client.streamId || null,
streamId: client.streamId || null,
deletedBy: client.user.id,
deletedBy: client.user.id,
});
});
} else if (client.anonId) {
} else if (client.anonId) {
- ids = db.deleteAnonChatMessages(client.anonId, {
+ ids = await db.deleteAnonChatMessages(client.anonId, {
streamId: client.streamId || null,
streamId: client.streamId || null,
deletedBy: null,
deletedBy: null,
});
});
@@ -580,10 +580,10 @@ class ChatServer {
@@ -580,10 +580,10 @@ class ChatServer {
return;
return;
}
}
- this._broadcastDeletedMessages(client.streamId || null, ids);
+ await this._broadcastDeletedMessages(client.streamId || null, ids);
try {
try {
- db.logModerationAction({
+ await db.logModerationAction({
scope_type: client.streamId ? 'stream' : 'site',
scope_type: client.streamId ? 'stream' : 'site',
scope_id: client.streamId || undefined,
scope_id: client.streamId || undefined,
actor_user_id: client.user?.id || undefined,
actor_user_id: client.user?.id || undefined,
@@ -607,7 +607,7 @@ class ChatServer {
@@ -607,7 +607,7 @@ class ChatServer {
* Build a deduplicated list of users in a given stream (or global if null).
* Build a deduplicated list of users in a given stream (or global if null).
* Returns { logged: [{username, display_name, avatar_url, role}], anonCount: N }
* Returns { logged: [{username, display_name, avatar_url, role}], anonCount: N }
*/
*/
- getUserList(streamId) {
+ async getUserList(streamId) {
const seen = new Set();
const seen = new Set();
const logged = [];
const logged = [];
let anonCount = 0, hiddenCount = 0;
let anonCount = 0, hiddenCount = 0;
@@ -617,7 +617,7 @@ class ChatServer {
@@ -617,7 +617,7 @@ class ChatServer {
if (seen.has(c.user.id)) continue;
if (seen.has(c.user.id)) continue;
seen.add(c.user.id);
seen.add(c.user.id);
// chat.presence_prefs: someone who turned off "show my name in user lists" is counted, not named.
// chat.presence_prefs: someone who turned off "show my name in user lists" is counted, not named.
- if (this._hiddenFromUserList(c.user)) { hiddenCount++; continue; }
+ if (await this._hiddenFromUserList(c.user)) { hiddenCount++; continue; }
logged.push({
logged.push({
username: c.user.username,
username: c.user.username,
display_name: c.user.display_name || c.user.username,
display_name: c.user.display_name || c.user.username,
@@ -641,8 +641,8 @@ class ChatServer {
@@ -641,8 +641,8 @@ class ChatServer {
* chat.presence_prefs). Synchronous: the cached record, fetched at join; not cached yet → fetch it
* chat.presence_prefs). Synchronous: the cached record, fetched at join; not cached yet → fetch it
* in the background and name them until it arrives.
* in the background and name them until it arrives.
*/
*/
- _hiddenFromUserList(user) {
- const subject = this._subjectOfUser(user);
+ async _hiddenFromUserList(user) {
+ const subject = await this._subjectOfUser(user);
if (!subject) return false;
if (!subject) return false;
const { presence } = require('../prefs/stores');
const { presence } = require('../prefs/stores');
const cached = presence.peek(subject);
const cached = presence.peek(subject);
@@ -667,7 +667,7 @@ class ChatServer {
@@ -667,7 +667,7 @@ class ChatServer {
const user = connected.get(String(name).toLowerCase());
const user = connected.get(String(name).toLowerCase());
let state = 'offline';
let state = 'offline';
if (user) {
if (user) {
- const subject = this._subjectOfUser(user);
+ const subject = await this._subjectOfUser(user);
if (!subject) state = 'online';
if (!subject) state = 'online';
else {
else {
let prefs = presence.peek(subject);
let prefs = presence.peek(subject);
@@ -687,9 +687,9 @@ class ChatServer {
@@ -687,9 +687,9 @@ class ChatServer {
presenceChanged(subject) {
presenceChanged(subject) {
if (!subject) return;
if (!subject) return;
const { presence } = require('../prefs/stores');
const { presence } = require('../prefs/stores');
- presence.get(subject).catch(() => {}).then(() => {
+ presence.get(subject).catch(() => {}).then(async () => {
const streams = new Set();
const streams = new Set();
- for (const [, c] of this.clients) if (c.user && this._subjectOfUser(c.user) === subject) streams.add(c.streamId ?? null);
+ for (const [, c] of this.clients) if (c.user && await this._subjectOfUser(c.user) === subject) streams.add(c.streamId ?? null);
for (const sid of streams) this.broadcastUsersList(sid);
for (const sid of streams) this.broadcastUsersList(sid);
});
});
}
}
@@ -722,7 +722,7 @@ class ChatServer {
@@ -722,7 +722,7 @@ class ChatServer {
/**
/**
* Handle a chat message
* Handle a chat message
*/
*/
- handleChatMessage(ws, client, msg) {
+ async handleChatMessage(ws, client, msg) {
let text = (msg.message || '').trim();
let text = (msg.message || '').trim();
// Absolute upper bound (DoS guard) = the highest value any channel/admin can configure
// Absolute upper bound (DoS guard) = the highest value any channel/admin can configure
// (admins can set a channel up to 6000). The REAL per-channel limit is enforced below at
// (admins can set a channel up to 6000). The REAL per-channel limit is enforced below at
@@ -738,7 +738,7 @@ class ChatServer {
@@ -738,7 +738,7 @@ class ChatServer {
// no input of any kind.
// no input of any kind.
// Offline channel chat has no stream of its own; bans and chat rules come from the channel's
// Offline channel chat has no stream of its own; bans and chat rules come from the channel's
// most recent stream, so a ban does not stop at the end of a broadcast.
// most recent stream, so a ban does not stop at the end of a broadcast.
- const modStreamId = client.streamId || this._moderationStreamFor(client);
+ const modStreamId = client.streamId || await this._moderationStreamFor(client);
if (client.user && ctx.isUserBanned(client.user.id, modStreamId)) {
if (client.user && ctx.isUserBanned(client.user.id, modStreamId)) {
this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
return;
return;
@@ -750,13 +750,13 @@ class ChatServer {
@@ -750,13 +750,13 @@ class ChatServer {
// ── Stream utility commands (!sr, !queue, !nowplaying, !skip) ──
// ── Stream utility commands (!sr, !queue, !nowplaying, !skip) ──
if (text.startsWith('!')) {
if (text.startsWith('!')) {
- this.handleBangCommand(ws, client, text);
+ await this.handleBangCommand(ws, client, text);
return;
return;
}
}
// ── Chat commands ────────────────────────────────────
// ── Chat commands ────────────────────────────────────
if (text.startsWith('/')) {
if (text.startsWith('/')) {
- this.handleCommand(ws, client, text);
+ await this.handleCommand(ws, client, text);
return;
return;
}
}
@@ -765,17 +765,17 @@ class ChatServer {
@@ -765,17 +765,17 @@ class ChatServer {
// stream id still posts into the live room (broadcastToChannelRoom).
// stream id still posts into the live room (broadcastToChannelRoom).
if (modStreamId && client.ip) {
if (modStreamId && client.ip) {
try {
try {
- const stream = ctx.getStreamById(modStreamId);
- const channel = stream?.channel_id ? ctx.getChannelById(stream.channel_id) : (stream ? ctx.getChannelByUserId(stream.user_id) : null);
+ const stream = await ctx.getStreamById(modStreamId);
+ const channel = stream?.channel_id ? await ctx.getChannelById(stream.channel_id) : (stream ? await ctx.getChannelByUserId(stream.user_id) : null);
if (channel) {
if (channel) {
- const settings = ctx.getChannelModerationSettings(channel.id);
+ const settings = await ctx.getChannelModerationSettings(channel.id);
if (settings?.ip_approval_mode) {
if (settings?.ip_approval_mode) {
const isStaffBypass = permissions.can(client.user, 'staff.moderation.bypass');
const isStaffBypass = permissions.can(client.user, 'staff.moderation.bypass');
const isOwner = client.user && stream && stream.user_id === client.user.id;
const isOwner = client.user && stream && stream.user_id === client.user.id;
if (!isStaffBypass && !isOwner) {
if (!isStaffBypass && !isOwner) {
if (!ctx.isIpApproved(channel.id, client.ip)) {
if (!ctx.isIpApproved(channel.id, client.ip)) {
// Auto-approve IPs that have existing non-deleted chat messages in this channel's streams
// Auto-approve IPs that have existing non-deleted chat messages in this channel's streams
- const existing = db.get(
+ const existing = await db.get(
`SELECT 1 FROM chat_messages cm
`SELECT 1 FROM chat_messages cm
JOIN ctx_streams s ON cm.stream_id = s.id
JOIN ctx_streams s ON cm.stream_id = s.id
WHERE s.channel_id = ? AND cm.is_deleted = 0
WHERE s.channel_id = ? AND cm.is_deleted = 0
@@ -789,7 +789,7 @@ class ChatServer {
@@ -789,7 +789,7 @@ class ChatServer {
} else {
} else {
// Hold the message for streamer approval
// Hold the message for streamer approval
const username = client.user ? client.user.display_name : client.anonId;
const username = client.user ? client.user.display_name : client.anonId;
- db.holdMessageForApproval({
+ await db.holdMessageForApproval({
channelId: channel.id,
channelId: channel.id,
streamId: modStreamId,
streamId: modStreamId,
ip: client.ip,
ip: client.ip,
@@ -814,7 +814,7 @@ class ChatServer {
@@ -814,7 +814,7 @@ class ChatServer {
}
}
// ── Channel moderation settings ──────────────────────
// ── Channel moderation settings ──────────────────────
- if (this._chatRulesBlock(ws, client, text, modStreamId)) return;
+ if (await this._chatRulesBlock(ws, client, text, modStreamId)) return;
const username = client.user ? client.user.display_name : client.anonId;
const username = client.user ? client.user.display_name : client.anonId;
const coreUsername = client.user ? client.user.username : null;
const coreUsername = client.user ? client.user.username : null;
@@ -828,7 +828,7 @@ class ChatServer {
@@ -828,7 +828,7 @@ class ChatServer {
let replyTo = null;
let replyTo = null;
if (replyToId) {
if (replyToId) {
try {
try {
- const parent = db.getChatMessageById(replyToId);
+ const parent = await db.getChatMessageById(replyToId);
const parentStillVisible = parent && !parent.is_deleted
const parentStillVisible = parent && !parent.is_deleted
&& (!parent.auto_delete_at || new Date(parent.auto_delete_at).getTime() > Date.now());
&& (!parent.auto_delete_at || new Date(parent.auto_delete_at).getTime() > Date.now());
if (parentStillVisible) {
if (parentStillVisible) {
@@ -844,8 +844,8 @@ class ChatServer {
@@ -844,8 +844,8 @@ class ChatServer {
const requestedAutoDeleteMinutes = parseInt(msg.auto_delete_minutes, 10);
const requestedAutoDeleteMinutes = parseInt(msg.auto_delete_minutes, 10);
const allowViewerAutoDelete = !client.streamId
const allowViewerAutoDelete = !client.streamId
- || this._getChannelChatSettings(client.streamId).viewer_auto_delete_enabled !== 0
- || permissions.canModerateStream(client.user, client.streamId);
+ || (await this._getChannelChatSettings(client.streamId)).viewer_auto_delete_enabled !== 0
+ || await permissions.canModerateStream(client.user, client.streamId);
const autoDeleteAt = Number.isFinite(requestedAutoDeleteMinutes)
const autoDeleteAt = Number.isFinite(requestedAutoDeleteMinutes)
&& requestedAutoDeleteMinutes >= MIN_CHAT_AUTO_DELETE_MINUTES
&& requestedAutoDeleteMinutes >= MIN_CHAT_AUTO_DELETE_MINUTES
&& allowViewerAutoDelete
&& allowViewerAutoDelete
@@ -896,7 +896,7 @@ class ChatServer {
@@ -896,7 +896,7 @@ class ChatServer {
let vipBadgePending = null;
let vipBadgePending = null;
if (client.user?.id && client.channelUserId) {
if (client.user?.id && client.channelUserId) {
try {
try {
- const r = vipBadges.forMessage(this._subjectOfUser(client.user), db.subjectFor(client.channelUserId));
+ const r = vipBadges.forMessage(await this._subjectOfUser(client.user), await db.subjectFor(client.channelUserId));
if (r.badge) chatMsg.vip_badge = r.badge;
if (r.badge) chatMsg.vip_badge = r.badge;
else vipBadgePending = r.pending;
else vipBadgePending = r.pending;
} catch { /* no badge */ }
} catch { /* no badge */ }
@@ -904,7 +904,7 @@ class ChatServer {
@@ -904,7 +904,7 @@ class ChatServer {
// Save to database
// Save to database
try {
try {
- const result = db.saveChatMessage({
+ const result = await db.saveChatMessage({
stream_id: client.streamId || null,
stream_id: client.streamId || null,
channel_user_id: client.channelUserId || null,
channel_user_id: client.channelUserId || null,
user_id: client.user?.id,
user_id: client.user?.id,
@@ -933,7 +933,7 @@ class ChatServer {
@@ -933,7 +933,7 @@ class ChatServer {
// stream), AI chat viewers (stream chat), the PowerChat overlay relay (any channel chat) —
// stream), AI chat viewers (stream chat), the PowerChat overlay relay (any channel chat) —
// are ONE call to Live per message; the coin result comes back for this socket.
// are ONE call to Live per message; the coin result comes back for this socket.
let isChannelMod = false;
let isChannelMod = false;
- try { isChannelMod = !!(client.user && client.channelUserId && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
+ try { isChannelMod = !!(client.user && client.channelUserId && await permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
const liveReactions = {
const liveReactions = {
award: !!(client.user?.id && client.streamId),
award: !!(client.user?.id && client.streamId),
ai: !!client.streamId,
ai: !!client.streamId,
@@ -943,13 +943,13 @@ class ChatServer {
@@ -943,13 +943,13 @@ class ChatServer {
// Welcome first-time chatters in this streamer's channel
// Welcome first-time chatters in this streamer's channel
if (client.streamId) {
if (client.streamId) {
try {
try {
- const stream = ctx.getStreamById(client.streamId);
+ const stream = await ctx.getStreamById(client.streamId);
if (stream?.user_id) {
if (stream?.user_id) {
const chatterKey = client.user ? `user:${client.user.id}` : `anon:${client.anonId}`;
const chatterKey = client.user ? `user:${client.user.id}` : `anon:${client.anonId}`;
- if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {
- db.recordFirstChat(chatterKey, stream.user_id);
+ if (await db.isFirstChatInChannel(chatterKey, stream.user_id)) {
+ await db.recordFirstChat(chatterKey, stream.user_id);
const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';
const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';
- this.broadcastToStream(client.streamId, {
+ await this.broadcastToStream(client.streamId, {
type: 'system',
type: 'system',
message: `Welcome ${welcomeName} to the chat! 👋`,
message: `Welcome ${welcomeName} to the chat! 👋`,
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(),
@@ -965,13 +965,13 @@ class ChatServer {
@@ -965,13 +965,13 @@ class ChatServer {
// viewers) plus the specific stream room. This subsumes cross-slot
// viewers) plus the specific stream room. This subsumes cross-slot
// forwarding so a viewer isn't interrupted when the streamer switches
// forwarding so a viewer isn't interrupted when the streamer switches
// slots or briefly drops offline.
// slots or briefly drops offline.
- this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);
+ await this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);
// Surface on the homepage global feed (tagged with the channel).
// Surface on the homepage global feed (tagged with the channel).
- if (client.streamId) this.forwardToGlobal(client.streamId, chatMsg);
- else this.forwardToGlobalByChannel(client.channelUserId, chatMsg);
+ if (client.streamId) await this.forwardToGlobal(client.streamId, chatMsg);
+ else await this.forwardToGlobalByChannel(client.channelUserId, chatMsg);
// Auto-translate (async): foreign → English for everyone, English → the channel's
// Auto-translate (async): foreign → English for everyone, English → the channel's
// language for a non-English streamer. Lands as a follow-up 'chat_translation'.
// language for a non-English streamer. Lands as a follow-up 'chat_translation'.
- this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);
+ await this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);
if (vipBadgePending) this._followVipBadge(vipBadgePending, chatMsg, client.channelUserId, client.streamId);
if (vipBadgePending) this._followVipBadge(vipBadgePending, chatMsg, client.channelUserId, client.streamId);
}
}
if (client.streamId) {
if (client.streamId) {
@@ -979,7 +979,7 @@ class ChatServer {
@@ -979,7 +979,7 @@ class ChatServer {
// Trigger server-side TTS synthesis (async, non-blocking).
// Trigger server-side TTS synthesis (async, non-blocking).
// identityKey uses the immutable login handle (or anon id) so the
// identityKey uses the immutable login handle (or anon id) so the
// per-user voice is stable even if the display name changes.
// per-user voice is stable even if the display name changes.
- this.synthesizeAndBroadcastTTS(
+ await this.synthesizeAndBroadcastTTS(
client.streamId,
client.streamId,
username,
username,
text,
text,
@@ -991,7 +991,7 @@ class ChatServer {
@@ -991,7 +991,7 @@ class ChatServer {
);
);
// Check for 101soundboards links in the message (async, non-blocking)
// Check for 101soundboards links in the message (async, non-blocking)
- this.processSoundboard(ws, client, text);
+ await this.processSoundboard(ws, client, text);
// AI chat viewers react to REAL typed chat (streamer or viewers) — sent with the
// AI chat viewers react to REAL typed chat (streamer or viewers) — sent with the
// other Live reactions below. Bots inject via broadcastToStream directly, so this
// other Live reactions below. Bots inject via broadcastToStream directly, so this
@@ -1000,13 +1000,13 @@ class ChatServer {
@@ -1000,13 +1000,13 @@ class ChatServer {
} else if (!client.channelUserId) {
} else if (!client.channelUserId) {
// Pure global chat (homepage) — offline channel messages were already
// Pure global chat (homepage) — offline channel messages were already
// delivered to the channel room above.
// delivered to the channel room above.
- this.broadcastGlobal(chatMsg);
+ await this.broadcastGlobal(chatMsg);
} else if (this._channelOwnerInRoom(client.channelUserId)) {
} else if (this._channelOwnerInRoom(client.channelUserId)) {
// OFFLINE channel chat with the channel OWNER present in their own room:
// OFFLINE channel chat with the channel OWNER present in their own room:
// synthesize TTS so their "TTS on my channel even when offline" option has
// synthesize TTS so their "TTS on my channel even when offline" option has
// audio to play (client-side settings + the one-speaking-tab lock decide
// audio to play (client-side settings + the one-speaking-tab lock decide
// whether it's actually audible). Owner absent = skip the synth cost.
// whether it's actually audible). Owner absent = skip the synth cost.
- this.synthesizeAndBroadcastTTS(
+ await this.synthesizeAndBroadcastTTS(
null,
null,
username,
username,
text,
text,
@@ -1068,11 +1068,11 @@ class ChatServer {
@@ -1068,11 +1068,11 @@ class ChatServer {
* the text of /me and /tts): length, anonymous, links, GIF hosts, followers-only, account age,
* the text of /me and /tts): length, anonymous, links, GIF hosts, followers-only, account age,
* the anti-slur filter. Tells the sender and returns true when the line is refused.
* the anti-slur filter. Tells the sender and returns true when the line is refused.
*/
*/
- _chatRulesBlock(ws, client, text, modStreamId) {
+ async _chatRulesBlock(ws, client, text, modStreamId) {
if (!modStreamId) return false;
if (!modStreamId) return false;
- const chatSettings = this._getChannelChatSettings(modStreamId);
+ const chatSettings = await this._getChannelChatSettings(modStreamId);
const isStaff = permissions.can(client.user, 'staff.moderation.bypass');
const isStaff = permissions.can(client.user, 'staff.moderation.bypass');
- const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);
+ const canModerateThisStream = await permissions.canModerateStream(client.user, modStreamId);
// Max message length
// Max message length
const maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));
const maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));
@@ -1091,7 +1091,7 @@ class ChatServer {
@@ -1091,7 +1091,7 @@ class ChatServer {
// VIP does not count), the streamer, channel moderators and chat staff. Unknown (Live not
// VIP does not count), the streamer, channel moderators and chat staff. Unknown (Live not
// asked yet, or unreachable) counts as not subscribed; _warmSubOnly asked before this line.
// asked yet, or unreachable) counts as not subscribed; _warmSubOnly asked before this line.
if (chatSettings.sub_only && !isStaff && !canModerateThisStream) {
if (chatSettings.sub_only && !isStaff && !canModerateThisStream) {
- const ownerId = ctx.getStreamById(modStreamId)?.user_id || null;
+ const ownerId = (await ctx.getStreamById(modStreamId))?.user_id || null;
const allowed = !!(client.user && ownerId && (Number(ownerId) === Number(client.user.id) || ctx.isSubscriber(client.user.id, ownerId)));
const allowed = !!(client.user && ownerId && (Number(ownerId) === Number(client.user.id) || ctx.isSubscriber(client.user.id, ownerId)));
if (!allowed) {
if (!allowed) {
this.sendTo(ws, { type: 'system', message: client.user ? SUB_ONLY_REFUSED : SUB_ONLY_ANON });
this.sendTo(ws, { type: 'system', message: client.user ? SUB_ONLY_REFUSED : SUB_ONLY_ANON });
@@ -1126,7 +1126,7 @@ class ChatServer {
@@ -1126,7 +1126,7 @@ class ChatServer {
// Followers only
// Followers only
if (chatSettings.followers_only && client.user && !isStaff) {
if (chatSettings.followers_only && client.user && !isStaff) {
- const stream = ctx.getStreamById(modStreamId);
+ const stream = await ctx.getStreamById(modStreamId);
if (stream && stream.user_id !== client.user.id && !ctx.isFollowing(client.user.id, stream.user_id)) {
if (stream && stream.user_id !== client.user.id && !ctx.isFollowing(client.user.id, stream.user_id)) {
this.sendTo(ws, { type: 'system', message: 'This chat is currently followers-only.' });
this.sendTo(ws, { type: 'system', message: 'This chat is currently followers-only.' });
return true;
return true;
@@ -1165,12 +1165,12 @@ class ChatServer {
@@ -1165,12 +1165,12 @@ class ChatServer {
* IP approval mode for text that is not a plain chat line (/me, /tts): refused until the
* IP approval mode for text that is not a plain chat line (/me, /tts): refused until the
* streamer approves the address (plain chat lines are held for review in handleChatMessage).
* streamer approves the address (plain chat lines are held for review in handleChatMessage).
*/
*/
- _awaitingIpApproval(ws, client, modStreamId) {
+ async _awaitingIpApproval(ws, client, modStreamId) {
if (!modStreamId || !client.ip) return false;
if (!modStreamId || !client.ip) return false;
try {
try {
- const stream = ctx.getStreamById(modStreamId);
- const channel = stream?.channel_id ? ctx.getChannelById(stream.channel_id) : (stream ? ctx.getChannelByUserId(stream.user_id) : null);
- if (!channel || !ctx.getChannelModerationSettings(channel.id)?.ip_approval_mode) return false;
+ const stream = await ctx.getStreamById(modStreamId);
+ const channel = stream?.channel_id ? await ctx.getChannelById(stream.channel_id) : (stream ? await ctx.getChannelByUserId(stream.user_id) : null);
+ if (!channel || !(await ctx.getChannelModerationSettings(channel.id))?.ip_approval_mode) return false;
if (client.user && (permissions.isGlobalModOrAbove(client.user) || stream.user_id === client.user.id)) return false;
if (client.user && (permissions.isGlobalModOrAbove(client.user) || stream.user_id === client.user.id)) return false;
if (ctx.isIpApproved(channel.id, client.ip)) return false;
if (ctx.isIpApproved(channel.id, client.ip)) return false;
} catch { return false; }
} catch { return false; }
@@ -1185,22 +1185,22 @@ class ChatServer {
@@ -1185,22 +1185,22 @@ class ChatServer {
*/
*/
async _warmSubOnly(client) {
async _warmSubOnly(client) {
if (!client || !client.user) return;
if (!client || !client.user) return;
- const modStreamId = client.streamId || this._moderationStreamFor(client);
- if (!modStreamId || !this._getChannelChatSettings(modStreamId).sub_only) return;
- if (permissions.canModerateStream(client.user, modStreamId)) return;
- const ownerId = ctx.getStreamById(modStreamId)?.user_id || null;
+ const modStreamId = client.streamId || await this._moderationStreamFor(client);
+ if (!modStreamId || !(await this._getChannelChatSettings(modStreamId)).sub_only) return;
+ if (await permissions.canModerateStream(client.user, modStreamId)) return;
+ const ownerId = (await ctx.getStreamById(modStreamId))?.user_id || null;
if (!ownerId || Number(ownerId) === Number(client.user.id)) return;
if (!ownerId || Number(ownerId) === Number(client.user.id)) return;
const state = ctx.subscriberState(client.user.id, ownerId);
const state = ctx.subscriberState(client.user.id, ownerId);
if (state !== true) await ctx.ensureSubscriber(client.user.id, ownerId, { maxAgeMs: state === false ? 5000 : 0 });
if (state !== true) await ctx.ensureSubscriber(client.user.id, ownerId, { maxAgeMs: state === false ? 5000 : 0 });
}
}
/** /me and /tts put text in the room: the same rules as a chat line. */
/** /me and /tts put text in the room: the same rules as a chat line. */
- _commandTextBlocked(ws, client, text) {
- const modStreamId = client.streamId || this._moderationStreamFor(client);
- return this._awaitingIpApproval(ws, client, modStreamId) || this._chatRulesBlock(ws, client, text, modStreamId);
+ async _commandTextBlocked(ws, client, text) {
+ const modStreamId = client.streamId || await this._moderationStreamFor(client);
+ return await this._awaitingIpApproval(ws, client, modStreamId) || await this._chatRulesBlock(ws, client, text, modStreamId);
}
}
- handleBangCommand(ws, client, text) {
+ async handleBangCommand(ws, client, text) {
const parts = text.trim().split(/\s+/);
const parts = text.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
const cmd = parts[0].toLowerCase();
@@ -1244,10 +1244,10 @@ class ChatServer {
@@ -1244,10 +1244,10 @@ class ChatServer {
}
}
if (client.streamId) {
if (client.streamId) {
- this.broadcastToStream(client.streamId, gottiMsg);
- this.forwardToGlobal(client.streamId, gottiMsg);
+ await this.broadcastToStream(client.streamId, gottiMsg);
+ await this.forwardToGlobal(client.streamId, gottiMsg);
} else {
} else {
- this.broadcastGlobal(gottiMsg);
+ await this.broadcastGlobal(gottiMsg);
}
}
return;
return;
}
}
@@ -1257,7 +1257,7 @@ class ChatServer {
@@ -1257,7 +1257,7 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'Soundboard commands only work in a stream chat.' });
this.sendTo(ws, { type: 'system', message: 'Soundboard commands only work in a stream chat.' });
return;
return;
}
}
- const chatSettings = this._getChannelChatSettings(client.streamId);
+ const chatSettings = await this._getChannelChatSettings(client.streamId);
if (chatSettings.soundboard_enabled === 0) {
if (chatSettings.soundboard_enabled === 0) {
this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });
this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });
return;
return;
@@ -1271,7 +1271,7 @@ class ChatServer {
@@ -1271,7 +1271,7 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'Usage: !sb <sound-id or 101soundboards URL> [100p|-100p] [0.5-3 speed]' });
this.sendTo(ws, { type: 'system', message: 'Usage: !sb <sound-id or 101soundboards URL> [100p|-100p] [0.5-3 speed]' });
return;
return;
}
}
- this.processSoundboard(ws, client, text);
+ await this.processSoundboard(ws, client, text);
return;
return;
}
}
@@ -1283,7 +1283,7 @@ class ChatServer {
@@ -1283,7 +1283,7 @@ class ChatServer {
const args = text.slice(parts[0].length).trim();
const args = text.slice(parts[0].length).trim();
try {
try {
- const stream = ctx.getStreamById(client.streamId);
+ const stream = await ctx.getStreamById(client.streamId);
if (!stream?.user_id) {
if (!stream?.user_id) {
this.sendTo(ws, { type: 'system', message: 'Could not resolve the current stream owner.' });
this.sendTo(ws, { type: 'system', message: 'Could not resolve the current stream owner.' });
return;
return;
@@ -1310,8 +1310,8 @@ class ChatServer {
@@ -1310,8 +1310,8 @@ class ChatServer {
userId: client.user.id,
userId: client.user.id,
username: client.user.display_name || client.user.username,
username: client.user.display_name || client.user.username,
input: args,
input: args,
- }).then(({ request }) => {
- this.broadcastToStream(client.streamId, {
+ }).then(async ({ request }) => {
+ await this.broadcastToStream(client.streamId, {
type: 'system',
type: 'system',
message: `${request.username} added “${request.title}”${request.duration_seconds ? ` (${Math.floor(request.duration_seconds / 60)}m${request.duration_seconds % 60}s)` : ''} to the media queue for ${request.cost} gold.`,
message: `${request.username} added “${request.title}”${request.duration_seconds ? ` (${Math.floor(request.duration_seconds / 60)}m${request.duration_seconds % 60}s)` : ''} to the media queue for ${request.cost} gold.`,
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(),
@@ -1360,14 +1360,14 @@ class ChatServer {
@@ -1360,14 +1360,14 @@ class ChatServer {
}
}
case '!skip': {
case '!skip': {
- if (!this.canModerate(client) && client.user?.id !== stream.user_id) {
+ if (!await this.canModerate(client) && client.user?.id !== stream.user_id) {
this.sendTo(ws, { type: 'system', message: 'Only the streamer or a moderator can skip media.' });
this.sendTo(ws, { type: 'system', message: 'Only the streamer or a moderator can skip media.' });
return;
return;
}
}
// Live runs finishCurrent(streamer, 'skipped') then startNext(streamer).
// Live runs finishCurrent(streamer, 'skipped') then startNext(streamer).
- ctx.effects.mediaQueue('skip', { streamerId: stream.user_id, actorUserId: client.user?.id || null }).then(({ ended, next }) => {
+ ctx.effects.mediaQueue('skip', { streamerId: stream.user_id, actorUserId: client.user?.id || null }).then(async ({ ended, next }) => {
if (ended) {
if (ended) {
- this.broadcastToStream(client.streamId, {
+ await this.broadcastToStream(client.streamId, {
type: 'system',
type: 'system',
message: `Skipped: ${ended.title}${next ? ` • Up next: ${next.title}` : ''}`,
message: `Skipped: ${ended.title}${next ? ` • Up next: ${next.title}` : ''}`,
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(),
@@ -1414,7 +1414,7 @@ class ChatServer {
@@ -1414,7 +1414,7 @@ class ChatServer {
default:
default:
// Unknown !command → try a per-channel viewer-uploaded sound clip.
// Unknown !command → try a per-channel viewer-uploaded sound clip.
// Forward trailing tokens (e.g. "500p", "0.5") as pitch/speed args.
// Forward trailing tokens (e.g. "500p", "0.5") as pitch/speed args.
- this.triggerChannelSound(ws, client, stream, cmd.slice(1), parts.slice(1));
+ await this.triggerChannelSound(ws, client, stream, cmd.slice(1), parts.slice(1));
return;
return;
}
}
} catch (err) {
} catch (err) {
@@ -1437,14 +1437,14 @@ class ChatServer {
@@ -1437,14 +1437,14 @@ class ChatServer {
* Silent no-op when the command is not a registered sound so unknown
* Silent no-op when the command is not a registered sound so unknown
* commands don't spam the chat.
* commands don't spam the chat.
*/
*/
- triggerChannelSound(ws, client, stream, command, args = [], relay = null) {
+ async triggerChannelSound(ws, client, stream, command, args = [], relay = null) {
try {
try {
if (!stream?.user_id || !command) return;
if (!stream?.user_id || !command) return;
const cmd = String(command).toLowerCase();
const cmd = String(command).toLowerCase();
- const sound = db.getChannelSoundByCommand(stream.user_id, cmd);
+ const sound = await db.getChannelSoundByCommand(stream.user_id, cmd);
if (!sound) return; // not a sound command — stay silent
if (!sound) return; // not a sound command — stay silent
- const chatSettings = this._getChannelChatSettings(client.streamId);
+ const chatSettings = await this._getChannelChatSettings(client.streamId);
if (chatSettings.custom_sounds_enabled === 0) {
if (chatSettings.custom_sounds_enabled === 0) {
if (ws) this.sendTo(ws, { type: 'system', message: 'This streamer has disabled chat sound commands.' });
if (ws) this.sendTo(ws, { type: 'system', message: 'This streamer has disabled chat sound commands.' });
return;
return;
@@ -1528,7 +1528,7 @@ class ChatServer {
@@ -1528,7 +1528,7 @@ class ChatServer {
// Persist the announce so it survives a reload — history rebuilds the
// Persist the announce so it survives a reload — history rebuilds the
// rich sound row from metadata (the audio itself is never replayed).
// rich sound row from metadata (the audio itself is never replayed).
try {
try {
- const saved = db.saveChatMessage({
+ const saved = await db.saveChatMessage({
stream_id: client.streamId || null,
stream_id: client.streamId || null,
channel_user_id: stream.user_id,
channel_user_id: stream.user_id,
user_id: client.user?.id,
user_id: client.user?.id,
@@ -1542,10 +1542,10 @@ class ChatServer {
@@ -1542,10 +1542,10 @@ class ChatServer {
});
});
if (saved.lastInsertRowid) soundMsg.id = Number(saved.lastInsertRowid);
if (saved.lastInsertRowid) soundMsg.id = Number(saved.lastInsertRowid);
} catch { /* non-critical */ }
} catch { /* non-critical */ }
- this.broadcastToStream(client.streamId, soundMsg);
+ await this.broadcastToStream(client.streamId, soundMsg);
// Also surface it on the global chat feed / global overlay (with stream_channel).
// Also surface it on the global chat feed / global overlay (with stream_channel).
- this.forwardToGlobal(client.streamId, soundMsg);
- audioQueue.enqueue({
+ await this.forwardToGlobal(client.streamId, soundMsg);
+ await audioQueue.enqueue({
kind: 'channel-sound',
kind: 'channel-sound',
streamId: client.streamId,
streamId: client.streamId,
requestedBy: username,
requestedBy: username,
@@ -1562,7 +1562,7 @@ class ChatServer {
@@ -1562,7 +1562,7 @@ class ChatServer {
/**
/**
* Handle chat commands
* Handle chat commands
*/
*/
- handleCommand(ws, client, text) {
+ async handleCommand(ws, client, text) {
const parts = text.slice(1).split(' ');
const parts = text.slice(1).split(' ');
const cmd = parts[0].toLowerCase();
const cmd = parts[0].toLowerCase();
const args = parts.slice(1).join(' ');
const args = parts.slice(1).join(' ');
@@ -1571,7 +1571,7 @@ class ChatServer {
@@ -1571,7 +1571,7 @@ class ChatServer {
switch (cmd) {
switch (cmd) {
case 'ai': {
case 'ai': {
// /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.
// /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.
- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'Only moderators can control the AI viewers.' }); return; }
+ if (!await this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'Only moderators can control the AI viewers.' }); return; }
// Live's ai-chatbot-service.onModCommand answers (AI viewers stay in Live until OpenVibe.AI).
// Live's ai-chatbot-service.onModCommand answers (AI viewers stay in Live until OpenVibe.AI).
ctx.effects.aiModCommand(client.channelUserId, client.streamId, parts.slice(1), { by: client.user?.username })
ctx.effects.aiModCommand(client.channelUserId, client.streamId, parts.slice(1), { by: client.user?.username })
.then((reply) => this.sendTo(ws, { type: 'system', message: reply || 'ok' }))
.then((reply) => this.sendTo(ws, { type: 'system', message: reply || 'ok' }))
@@ -1583,7 +1583,7 @@ class ChatServer {
@@ -1583,7 +1583,7 @@ class ChatServer {
type: 'system',
type: 'system',
message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +
message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +
`\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +
`\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +
- (this.canModerate(client)
+ (await this.canModerate(client)
? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear (screens only), /slow <seconds|off>, /subonly [off], /skiptts [id], /cleartts`
? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear (screens only), /slow <seconds|off>, /subonly [off], /skiptts [id], /cleartts`
: ''),
: ''),
});
});
@@ -1594,7 +1594,7 @@ class ChatServer {
@@ -1594,7 +1594,7 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'Usage: /tts <message>' });
this.sendTo(ws, { type: 'system', message: 'Usage: /tts <message>' });
return;
return;
}
}
- if (this._commandTextBlocked(ws, client, args)) return;
+ if (await this._commandTextBlocked(ws, client, args)) return;
{
{
const ttsMsg = {
const ttsMsg = {
type: 'tts',
type: 'tts',
@@ -1615,10 +1615,10 @@ class ChatServer {
@@ -1615,10 +1615,10 @@ class ChatServer {
}
}
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
- this._broadcastToRoom(client, ttsMsg);
+ await this._broadcastToRoom(client, ttsMsg);
// Also synthesize server-side TTS for site-wide mode
// Also synthesize server-side TTS for site-wide mode
- this.synthesizeAndBroadcastTTS(
+ await this.synthesizeAndBroadcastTTS(
client.streamId,
client.streamId,
ttsMsg.username,
ttsMsg.username,
args,
args,
@@ -1655,7 +1655,7 @@ class ChatServer {
@@ -1655,7 +1655,7 @@ class ChatServer {
break;
break;
}
}
try {
try {
- const stream = ctx.getStreamById(client.streamId);
+ const stream = await ctx.getStreamById(client.streamId);
if (stream && stream.started_at) {
if (stream && stream.started_at) {
const start = new Date(stream.started_at.replace(' ', 'T') + 'Z').getTime();
const start = new Date(stream.started_at.replace(' ', 'T') + 'Z').getTime();
const elapsed = Date.now() - start;
const elapsed = Date.now() - start;
@@ -1688,9 +1688,9 @@ class ChatServer {
@@ -1688,9 +1688,9 @@ class ChatServer {
this.sendTo(ws, { type: 'system', message: 'Usage: /me <action>' });
this.sendTo(ws, { type: 'system', message: 'Usage: /me <action>' });
break;
break;
}
}
- if (this._commandTextBlocked(ws, client, args)) break;
+ if (await this._commandTextBlocked(ws, client, args)) break;
const username = client.user?.display_name || client.anonId;
const username = client.user?.display_name || client.anonId;
- this._broadcastToRoom(client, {
+ await this._broadcastToRoom(client, {
type: 'chat',
type: 'chat',
username,
username,
core_username: client.user?.username || null,
core_username: client.user?.username || null,
@@ -1720,19 +1720,19 @@ class ChatServer {
@@ -1720,19 +1720,19 @@ class ChatServer {
// The room's TTS and sound queue (./audio-queue.js): moderators and the broadcaster.
// The room's TTS and sound queue (./audio-queue.js): moderators and the broadcaster.
// /skiptts [request id] skips the clip playing now (or that request); /cleartts
// /skiptts [request id] skips the clip playing now (or that request); /cleartts
// skips everything playing or waiting.
// skips everything playing or waiting.
- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); return; }
+ if (!await this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); return; }
const room = audioQueue.roomKey({ streamId: client.streamId, channelUserId: client.streamId ? null : client.channelUserId });
const room = audioQueue.roomKey({ streamId: client.streamId, channelUserId: client.streamId ? null : client.channelUserId });
if (!room) { this.sendTo(ws, { type: 'system', message: 'No TTS or sounds here.' }); return; }
if (!room) { this.sendTo(ws, { type: 'system', message: 'No TTS or sounds here.' }); return; }
const actor = client.user ? `user:${client.user.username}` : null;
const actor = client.user ? `user:${client.user.username}` : null;
if (cmd === 'skiptts') {
if (cmd === 'skiptts') {
const id = parseInt(argParts[0], 10);
const id = parseInt(argParts[0], 10);
- const skipped = audioQueue.skip(room, { id: Number.isFinite(id) && id > 0 ? id : null, actor });
+ const skipped = await audioQueue.skip(room, { id: Number.isFinite(id) && id > 0 ? id : null, actor });
this.sendTo(ws, { type: 'system', message: skipped ? `Skipped ${skipped.kind === 'tts' ? 'TTS' : 'sound'} #${skipped.id}${skipped.requested_by ? ` from ${skipped.requested_by}` : ''}.` : 'Nothing to skip.' });
this.sendTo(ws, { type: 'system', message: skipped ? `Skipped ${skipped.kind === 'tts' ? 'TTS' : 'sound'} #${skipped.id}${skipped.requested_by ? ` from ${skipped.requested_by}` : ''}.` : 'Nothing to skip.' });
- if (skipped) this.logChatModeration(client, 'tts_skip', { request_id: skipped.id, kind: skipped.kind });
+ if (skipped) await this.logChatModeration(client, 'tts_skip', { request_id: skipped.id, kind: skipped.kind });
} else {
} else {
- const ids = audioQueue.clear(room, { actor });
+ const ids = await audioQueue.clear(room, { actor });
this.sendTo(ws, { type: 'system', message: ids.length ? `Cleared ${ids.length} TTS/sound request${ids.length === 1 ? '' : 's'}.` : 'The TTS and sound queue is empty.' });
this.sendTo(ws, { type: 'system', message: ids.length ? `Cleared ${ids.length} TTS/sound request${ids.length === 1 ? '' : 's'}.` : 'The TTS and sound queue is empty.' });
- if (ids.length) this.logChatModeration(client, 'tts_clear', { request_ids: ids });
+ if (ids.length) await this.logChatModeration(client, 'tts_clear', { request_ids: ids });
}
}
return;
return;
}
}
@@ -1740,17 +1740,17 @@ class ChatServer {
@@ -1740,17 +1740,17 @@ class ChatServer {
case 'clear':
case 'clear':
// Twitch semantics: every screen in the room is cleared, nothing is deleted. Removing
// Twitch semantics: every screen in the room is cleared, nothing is deleted. Removing
// lines for good is a purge (dashboard) or a delete.
// lines for good is a purge (dashboard) or a delete.
- if (this.canModerate(client)) {
- this._broadcastToRoom(client, { type: 'clear' });
+ if (await this.canModerate(client)) {
+ await this._broadcastToRoom(client, { type: 'clear' });
this.sendTo(ws, { type: 'system', message: 'Chat cleared on screen; messages stay in history — use purge to remove them.' });
this.sendTo(ws, { type: 'system', message: 'Chat cleared on screen; messages stay in history — use purge to remove them.' });
- this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');
+ await this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');
} else {
} else {
this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
}
}
break;
break;
case 'slow': {
case 'slow': {
- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }
+ if (!await this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }
let seconds;
let seconds;
if (args === 'off' || args === 'disable' || args === '0') {
if (args === 'off' || args === 'disable' || args === '0') {
seconds = 0;
seconds = 0;
@@ -1759,16 +1759,16 @@ class ChatServer {
@@ -1759,16 +1759,16 @@ class ChatServer {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;
if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;
}
}
this._setChannelModes(ws, client, { slow_mode_seconds: seconds }, 'slow mode')
this._setChannelModes(ws, client, { slow_mode_seconds: seconds }, 'slow mode')
- .then((ok) => { if (ok) this.logChatModeration(client, 'slowmode_update', { seconds }); });
+ .then(async (ok) => { if (ok) await this.logChatModeration(client, 'slowmode_update', { seconds }); });
break;
break;
}
}
case 'subonly': {
case 'subonly': {
// /subonly [on] · /subonly off — the streamer, channel mods and chat staff.
// /subonly [on] · /subonly off — the streamer, channel mods and chat staff.
- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }
+ if (!await this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'You do not have permission.' }); break; }
const off = ['off', 'disable', '0', 'false', 'no'].includes(String(args || '').trim().toLowerCase());
const off = ['off', 'disable', '0', 'false', 'no'].includes(String(args || '').trim().toLowerCase());
this._setChannelModes(ws, client, { sub_only: off ? 0 : 1 }, 'sub-only mode')
this._setChannelModes(ws, client, { sub_only: off ? 0 : 1 }, 'sub-only mode')
- .then((ok) => { if (ok) this.logChatModeration(client, 'subonly_update', { enabled: !off }); });
+ .then(async (ok) => { if (ok) await this.logChatModeration(client, 'subonly_update', { enabled: !off }); });
break;
break;
}
}
@@ -1789,11 +1789,11 @@ class ChatServer {
@@ -1789,11 +1789,11 @@ class ChatServer {
visibility: 'public',
visibility: 'public',
user_id: userId || undefined,
user_id: userId || undefined,
stream_id: client.streamId || undefined,
stream_id: client.streamId || undefined,
- }).then((paste) => {
+ }).then(async (paste) => {
const siteUrl = process.env.SITE_URL || '';
const siteUrl = process.env.SITE_URL || '';
const pasteUrl = `${siteUrl}/p/${paste.slug}`;
const pasteUrl = `${siteUrl}/p/${paste.slug}`;
// Show link to everyone in stream
// Show link to everyone in stream
- this._broadcastToRoom(client, {
+ await this._broadcastToRoom(client, {
type: 'system',
type: 'system',
message: `📋 ${client.displayName || client.username || 'Anonymous'} shared a paste: ${pasteUrl}`,
message: `📋 ${client.displayName || client.username || 'Anonymous'} shared a paste: ${pasteUrl}`,
});
});
@@ -1825,7 +1825,7 @@ class ChatServer {
@@ -1825,7 +1825,7 @@ class ChatServer {
* the row (re-checking the moderator) and Chat's ban cache is refreshed before the reply.
* the row (re-checking the moderator) and Chat's ban cache is refreshed before the reply.
*/
*/
async _handleModAction(ws, client, action, args) {
async _handleModAction(ws, client, action, args) {
- if (!this.canModerate(client)) {
+ if (!await this.canModerate(client)) {
this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
return;
return;
}
}
@@ -1833,7 +1833,7 @@ class ChatServer {
@@ -1833,7 +1833,7 @@ class ChatServer {
const target = args.split(' ')[0];
const target = args.split(' ')[0];
if (!target) return;
if (!target) return;
// The stream whose moderators may do this (offline channel rooms: the latest stream).
// The stream whose moderators may do this (offline channel rooms: the latest stream).
- const moderationStreamId = client.streamId || this._moderationStreamFor(client);
+ const moderationStreamId = client.streamId || await this._moderationStreamFor(client);
// Where the ban applies. In an offline channel room it is that channel's stream: a null
// Where the ban applies. In an offline channel room it is that channel's stream: a null
// stream id is a SITE-WIDE ban row (and /unban with null lifts site-wide rows), which
// stream id is a SITE-WIDE ban row (and /unban with null lifts site-wide rows), which
// only global chat — where canModerate() already requires global staff — may write.
// only global chat — where canModerate() already requires global staff — may write.
@@ -1852,10 +1852,10 @@ class ChatServer {
@@ -1852,10 +1852,10 @@ class ChatServer {
}
}
await banEffect({ action: 'ban', user_id: targetUser.id, reason: 'Banned by moderator', banned_by: client.user.id });
await banEffect({ action: 'ban', user_id: targetUser.id, reason: 'Banned by moderator', banned_by: client.user.id });
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
- this._broadcastToRoom(client, {
+ await this._broadcastToRoom(client, {
type: 'system', message: `${target} has been banned.`
type: 'system', message: `${target} has been banned.`
});
});
- this.logChatModeration(client, scoped ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);
+ await this.logChatModeration(client, scoped ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);
} else {
} else {
// Ban by anon ID
// Ban by anon ID
const anonTarget = this.findClientByAnonId(target, client.streamId);
const anonTarget = this.findClientByAnonId(target, client.streamId);
@@ -1863,7 +1863,7 @@ class ChatServer {
@@ -1863,7 +1863,7 @@ class ChatServer {
await banEffect({ action: 'ban', ip_address: anonTarget.ip, anon_id: target, reason: 'Banned by moderator', banned_by: client.user.id });
await banEffect({ action: 'ban', ip_address: anonTarget.ip, anon_id: target, reason: 'Banned by moderator', banned_by: client.user.id });
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
}
}
- this.logChatModeration(client, scoped ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });
+ await this.logChatModeration(client, scoped ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });
}
}
break;
break;
}
}
@@ -1873,7 +1873,7 @@ class ChatServer {
@@ -1873,7 +1873,7 @@ class ChatServer {
const expires = new Date(Date.now() + duration * 1000).toISOString();
const expires = new Date(Date.now() + duration * 1000).toISOString();
if (targetUser) {
if (targetUser) {
await banEffect({ action: 'ban', user_id: targetUser.id, reason: `Timeout ${duration}s`, banned_by: client.user.id, expires_at: expires });
await banEffect({ action: 'ban', user_id: targetUser.id, reason: `Timeout ${duration}s`, banned_by: client.user.id, expires_at: expires });
- this.logChatModeration(client, scoped ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);
+ await this.logChatModeration(client, scoped ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);
}
}
this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });
this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });
break;
break;
@@ -1882,7 +1882,7 @@ class ChatServer {
@@ -1882,7 +1882,7 @@ class ChatServer {
const targetUser = await ctx.ensureUserByUsername(target);
const targetUser = await ctx.ensureUserByUsername(target);
if (targetUser) {
if (targetUser) {
await banEffect({ action: 'unban', user_id: targetUser.id });
await banEffect({ action: 'unban', user_id: targetUser.id });
- this.logChatModeration(client, scoped ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);
+ await this.logChatModeration(client, scoped ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);
}
}
this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });
this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });
break;
break;
@@ -1909,9 +1909,9 @@ class ChatServer {
@@ -1909,9 +1909,9 @@ class ChatServer {
// TTS audio delivery: to the stream room while live, to the CHANNEL room for
// TTS audio delivery: to the stream room while live, to the CHANNEL room for
// offline channel chat (streamId null).
// offline channel chat (streamId null).
- _broadcastTtsPayload(streamId, channelUserId, payload) {
- if (streamId) this.broadcastToStream(streamId, payload);
- else if (channelUserId) this.broadcastToChannelRoom(channelUserId, null, payload);
+ async _broadcastTtsPayload(streamId, channelUserId, payload) {
+ if (streamId) await this.broadcastToStream(streamId, payload);
+ else if (channelUserId) await this.broadcastToChannelRoom(channelUserId, null, payload);
}
}
// streamId may be null for OFFLINE channel chat — pass channelUserId instead and
// streamId may be null for OFFLINE channel chat — pass channelUserId instead and
@@ -1920,7 +1920,7 @@ class ChatServer {
@@ -1920,7 +1920,7 @@ class ChatServer {
// Queues the utterance (./audio-queue.js); it is synthesized and sent when its turn comes.
// Queues the utterance (./audio-queue.js); it is synthesized and sent when its turn comes.
// Returns the queue's answer ({ queued, id } or { queued: false, reason }) or undefined when
// Returns the queue's answer ({ queued, id } or { queued: false, reason }) or undefined when
// the message is not read at all.
// the message is not read at all.
- synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null, channelUserId = null, ttsKey = null) {
+ async synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null, channelUserId = null, ttsKey = null) {
// Queue accounting key: per-stream when live, per-channel when offline.
// Queue accounting key: per-stream when live, per-channel when offline.
const queueKey = streamId || (channelUserId ? `ch:${channelUserId}` : null);
const queueKey = streamId || (channelUserId ? `ch:${channelUserId}` : null);
// Duplicate suppression by MESSAGE IDENTITY, not content: the same chat
// Duplicate suppression by MESSAGE IDENTITY, not content: the same chat
@@ -1949,7 +1949,7 @@ class ChatServer {
@@ -1949,7 +1949,7 @@ class ChatServer {
if (!settings.enabled) return;
if (!settings.enabled) return;
const limits = ttsEngine.getQueueLimits();
const limits = ttsEngine.getQueueLimits();
- const r = audioQueue.enqueue({
+ const r = await audioQueue.enqueue({
kind: 'tts',
kind: 'tts',
streamId: streamId || null,
streamId: streamId || null,
channelUserId: streamId ? null : channelUserId,
channelUserId: streamId ? null : channelUserId,
@@ -1983,7 +1983,7 @@ class ChatServer {
@@ -1983,7 +1983,7 @@ class ChatServer {
// falls back to the site default when unset. Without this the server synth always
// falls back to the site default when unset. Without this the server synth always
// truncated at the global 200 even when the channel allowed more.
// truncated at the global 200 even when the channel allowed more.
let ttsMaxOverride;
let ttsMaxOverride;
- try { ttsMaxOverride = Number(this._getChannelChatSettings(p.streamId).tts_max_length) || undefined; } catch { /* use engine default */ }
+ try { ttsMaxOverride = Number((await this._getChannelChatSettings(p.streamId)).tts_max_length) || undefined; } catch { /* use engine default */ }
let result;
let result;
if (!voiceId && settings.perUserVoices) {
if (!voiceId && settings.perUserVoices) {
@@ -2079,7 +2079,7 @@ class ChatServer {
@@ -2079,7 +2079,7 @@ class ChatServer {
const streamId = client?.streamId || null;
const streamId = client?.streamId || null;
if (!streamId) return;
if (!streamId) return;
- const chatSettings = this._getChannelChatSettings(streamId);
+ const chatSettings = await this._getChannelChatSettings(streamId);
if (chatSettings.soundboard_enabled === 0) {
if (chatSettings.soundboard_enabled === 0) {
if (String(text || '').trim().startsWith('!sb')) {
if (String(text || '').trim().startsWith('!sb')) {
this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });
this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });
@@ -2166,7 +2166,7 @@ class ChatServer {
@@ -2166,7 +2166,7 @@ class ChatServer {
};
};
// Persist so the announce survives a reload (rebuilt from metadata).
// Persist so the announce survives a reload (rebuilt from metadata).
try {
try {
- const saved = db.saveChatMessage({
+ const saved = await db.saveChatMessage({
stream_id: streamId,
stream_id: streamId,
user_id: client.user?.id,
user_id: client.user?.id,
anon_id: client.anonId,
anon_id: client.anonId,
@@ -2178,9 +2178,9 @@ class ChatServer {
@@ -2178,9 +2178,9 @@ class ChatServer {
});
});
if (saved.lastInsertRowid) sbMsg.id = Number(saved.lastInsertRowid);
if (saved.lastInsertRowid) sbMsg.id = Number(saved.lastInsertRowid);
} catch { /* non-critical */ }
} catch { /* non-critical */ }
- this.broadcastToStream(streamId, sbMsg);
+ await this.broadcastToStream(streamId, sbMsg);
- audioQueue.enqueue({
+ await audioQueue.enqueue({
kind: 'soundboard',
kind: 'soundboard',
streamId,
streamId,
requestedBy: username,
requestedBy: username,
@@ -2202,16 +2202,16 @@ class ChatServer {
@@ -2202,16 +2202,16 @@ class ChatServer {
* Uses the permission layer: admin, global_mod, stream owner, or channel mod.
* Uses the permission layer: admin, global_mod, stream owner, or channel mod.
* Streamers do NOT get mod powers in other people's chats.
* Streamers do NOT get mod powers in other people's chats.
*/
*/
- canModerate(client) {
+ async canModerate(client) {
if (!client.user) return false;
if (!client.user) return false;
// Offline channel chat: the channel's latest stream stands in (see _moderationStreamFor).
// Offline channel chat: the channel's latest stream stands in (see _moderationStreamFor).
- const sid = client.streamId || this._moderationStreamFor(client);
- return !!sid && permissions.canModerateStream(client.user, sid) || permissions.isGlobalModOrAbove(client.user);
+ const sid = client.streamId || await this._moderationStreamFor(client);
+ return !!sid && await permissions.canModerateStream(client.user, sid) || permissions.isGlobalModOrAbove(client.user);
}
}
/** @deprecated Use canModerate(client) — kept temporarily for any external callers */
/** @deprecated Use canModerate(client) — kept temporarily for any external callers */
- isMod(client) {
- return this.canModerate(client);
+ async isMod(client) {
+ return await this.canModerate(client);
}
}
findClientByAnonId(anonId, streamId) {
findClientByAnonId(anonId, streamId) {
@@ -2276,15 +2276,15 @@ class ChatServer {
@@ -2276,15 +2276,15 @@ class ChatServer {
}
}
/** Language a channel lives in ('en' when unknown). Live's i18n decides; cached in live-context. */
/** Language a channel lives in ('en' when unknown). Live's i18n decides; cached in live-context. */
- _channelLanguage(channelUserId) {
+ async _channelLanguage(channelUserId) {
if (!channelUserId) return 'en';
if (!channelUserId) return 'en';
- try { return ctx.channelLanguage(channelUserId); } catch { return 'en'; }
+ try { return await ctx.channelLanguage(channelUserId); } catch { return 'en'; }
}
}
/** A signed-in user's Network subject (the projection's subject_id, else the ctx_users row). */
/** A signed-in user's Network subject (the projection's subject_id, else the ctx_users row). */
- _subjectOfUser(user) {
+ async _subjectOfUser(user) {
if (!user) return null;
if (!user) return null;
- return user.subject_id || db.subjectFor(user.id) || null;
+ return user.subject_id || await db.subjectFor(user.id) || null;
}
}
/**
/**
@@ -2293,13 +2293,13 @@ class ChatServer {
@@ -2293,13 +2293,13 @@ class ChatServer {
* keep it in the row's metadata so history shows it. Fire-and-forget; never throws.
* keep it in the row's metadata so history shows it. Fire-and-forget; never throws.
*/
*/
_followVipBadge(pending, chatMsg, channelUserId, streamId) {
_followVipBadge(pending, chatMsg, channelUserId, streamId) {
- Promise.resolve(pending).then((badge) => {
+ Promise.resolve(pending).then(async (badge) => {
if (!badge || !chatMsg || !chatMsg.id) return;
if (!badge || !chatMsg || !chatMsg.id) return;
const evt = { type: 'chat_vip_badge', id: chatMsg.id, vip_badge: badge, stream_id: streamId || null, channel_user_id: channelUserId || null, timestamp: new Date().toISOString() };
const evt = { type: 'chat_vip_badge', id: chatMsg.id, vip_badge: badge, stream_id: streamId || null, channel_user_id: channelUserId || null, timestamp: new Date().toISOString() };
- this.broadcastToChannelRoom(channelUserId, streamId, evt);
- if (streamId) this.forwardToGlobal(streamId, evt);
- else this.forwardToGlobalByChannel(channelUserId, evt);
- try { db.mergeChatMessageMetadata(chatMsg.id, { vip_badge: badge }); } catch { /* */ }
+ await this.broadcastToChannelRoom(channelUserId, streamId, evt);
+ if (streamId) await this.forwardToGlobal(streamId, evt);
+ else await this.forwardToGlobalByChannel(channelUserId, evt);
+ try { await db.mergeChatMessageMetadata(chatMsg.id, { vip_badge: badge }); } catch { /* */ }
}).catch(() => { /* no badge */ });
}).catch(() => { /* no badge */ });
}
}
@@ -2308,22 +2308,22 @@ class ChatServer {
@@ -2308,22 +2308,22 @@ class ChatServer {
* 'chat_translation' event (clients attach it under the message by id). Persisted into
* 'chat_translation' event (clients attach it under the message by id). Persisted into
* chat_messages.metadata so history shows it too. Fire-and-forget; never throws.
* chat_messages.metadata so history shows it too. Fire-and-forget; never throws.
*/
*/
- _maybeTranslate(chatMsg, channelUserId, streamId) {
+ async _maybeTranslate(chatMsg, channelUserId, streamId) {
if (!chatMsg || !chatMsg.message || chatMsg.message_type && chatMsg.message_type !== 'chat') return;
if (!chatMsg || !chatMsg.message || chatMsg.message_type && chatMsg.message_type !== 'chat') return;
// Live's i18n translates (and answers null when translation is unavailable).
// Live's i18n translates (and answers null when translation is unavailable).
let chanUid = channelUserId || null;
let chanUid = channelUserId || null;
- if (!chanUid && streamId) { try { chanUid = ctx.getStreamById(streamId)?.user_id || null; } catch { /* */ } }
+ if (!chanUid && streamId) { try { chanUid = (await ctx.getStreamById(streamId))?.user_id || null; } catch { /* */ } }
const text = String(chatMsg.message).replace(/^\s*\.\s?/, ''); // ".msg" = tts-off marker
const text = String(chatMsg.message).replace(/^\s*\.\s?/, ''); // ".msg" = tts-off marker
- ctx.effects.translate(text, chanUid).then((tr) => {
+ ctx.effects.translate(text, chanUid).then(async (tr) => {
if (!tr || !tr.text) return;
if (!tr || !tr.text) return;
const evt = {
const evt = {
type: 'chat_translation', id: chatMsg.id || null, from: tr.from, to: tr.to, text: tr.text,
type: 'chat_translation', id: chatMsg.id || null, from: tr.from, to: tr.to, text: tr.text,
stream_id: streamId || null, channel_user_id: chanUid, timestamp: new Date().toISOString(),
stream_id: streamId || null, channel_user_id: chanUid, timestamp: new Date().toISOString(),
};
};
- this.broadcastToChannelRoom(chanUid, streamId, evt);
- if (streamId) this.forwardToGlobal(streamId, evt);
- else this.forwardToGlobalByChannel(chanUid, evt);
- if (chatMsg.id) { try { db.mergeChatMessageMetadata(chatMsg.id, { translation: tr }); } catch { /* */ } }
+ await this.broadcastToChannelRoom(chanUid, streamId, evt);
+ if (streamId) await this.forwardToGlobal(streamId, evt);
+ else await this.forwardToGlobalByChannel(chanUid, evt);
+ if (chatMsg.id) { try { await db.mergeChatMessageMetadata(chatMsg.id, { translation: tr }); } catch { /* */ } }
}).catch(() => { /* best-effort */ });
}).catch(() => { /* best-effort */ });
}
}
@@ -2332,17 +2332,17 @@ class ChatServer {
@@ -2332,17 +2332,17 @@ class ChatServer {
* line frame (chat, /me, /tts and its audio), or null — the usual case, everyone gets it.
* line frame (chat, /me, /tts and its audio), or null — the usual case, everyone gets it.
* Computed once per frame object, however many rooms it goes to.
* Computed once per frame object, however many rooms it goes to.
*/
*/
- _blockersOf(data) {
+ async _blockersOf(data) {
if (!data || typeof data !== 'object' || !LINE_FRAMES.has(data.type)) return null;
if (!data || typeof data !== 'object' || !LINE_FRAMES.has(data.type)) return null;
if (this._blockMemo.has(data)) return this._blockMemo.get(data);
if (this._blockMemo.has(data)) return this._blockMemo.get(data);
let out = null;
let out = null;
try {
try {
let authorId = data.user_id || null;
let authorId = data.user_id || null;
if (!authorId && data.type === 'tts-audio' && /^user:/.test(String(data.sender_key || ''))) {
if (!authorId && data.type === 'tts-audio' && /^user:/.test(String(data.sender_key || ''))) {
- authorId = ctx.getUserByUsername(String(data.sender_key).slice(5))?.id || null;
+ authorId = (await ctx.getUserByUsername(String(data.sender_key).slice(5)))?.id || null;
}
}
- const subject = authorId ? db.subjectFor(authorId) : null;
- const list = subject ? networkBlocks.blockersOf(subject) : [];
+ const subject = authorId ? await db.subjectFor(authorId) : null;
+ const list = subject ? await networkBlocks.blockersOf(subject) : [];
if (list.length) out = new Set(list);
if (list.length) out = new Set(list);
} catch { out = null; }
} catch { out = null; }
this._blockMemo.set(data, out);
this._blockMemo.set(data, out);
@@ -2350,8 +2350,8 @@ class ChatServer {
@@ -2350,8 +2350,8 @@ class ChatServer {
}
}
/** Does this socket's person not get the frame (they blocked its author)? */
/** Does this socket's person not get the frame (they blocked its author)? */
- _blockedFor(client, blockers) {
- return !!(blockers && client.user && blockers.has(this._subjectOfUser(client.user)));
+ async _blockedFor(client, blockers) {
+ return !!(blockers && client.user && blockers.has(await this._subjectOfUser(client.user)));
}
}
/**
/**
@@ -2359,17 +2359,17 @@ class ChatServer {
@@ -2359,17 +2359,17 @@ class ChatServer {
* in an offline channel chat, else pure global chat. broadcastToStream(null) would reach every
* in an offline channel chat, else pure global chat. broadcastToStream(null) would reach every
* client without a stream — global chat AND every other channel's offline room.
* client without a stream — global chat AND every other channel's offline room.
*/
*/
- _broadcastToRoom(client, data) {
- if (client.streamId) this.broadcastToStream(client.streamId, data);
- else if (client.channelUserId) this.broadcastToChannelRoom(client.channelUserId, null, data);
- else this.broadcastGlobal(data);
+ async _broadcastToRoom(client, data) {
+ if (client.streamId) await this.broadcastToStream(client.streamId, data);
+ else if (client.channelUserId) await this.broadcastToChannelRoom(client.channelUserId, null, data);
+ else await this.broadcastGlobal(data);
}
}
- broadcastToStream(streamId, data) {
+ async broadcastToStream(streamId, data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
- const blockers = this._blockersOf(data);
+ const blockers = await this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
+ if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {
ws.send(msg);
ws.send(msg);
}
}
}
}
@@ -2381,12 +2381,12 @@ class ChatServer {
@@ -2381,12 +2381,12 @@ class ChatServer {
* live-session stream room (`streamId`, for backward-compat with clients that
* live-session stream room (`streamId`, for backward-compat with clients that
* joined before channel rooms existed). Deduped per connection.
* joined before channel rooms existed). Deduped per connection.
*/
*/
- broadcastToChannelRoom(channelUserId, streamId, data) {
+ async broadcastToChannelRoom(channelUserId, streamId, data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
- const blockers = this._blockersOf(data);
+ const blockers = await this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > MAX_SEND_BACKPRESSURE) continue;
if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > MAX_SEND_BACKPRESSURE) continue;
- if (this._blockedFor(client, blockers)) continue;
+ if (await this._blockedFor(client, blockers)) continue;
const inChannel = channelUserId && client.channelUserId === channelUserId;
const inChannel = channelUserId && client.channelUserId === channelUserId;
const inStream = streamId && client.streamId === streamId;
const inStream = streamId && client.streamId === streamId;
if (inChannel || inStream) ws.send(msg);
if (inChannel || inStream) ws.send(msg);
@@ -2398,27 +2398,27 @@ class ChatServer {
@@ -2398,27 +2398,27 @@ class ChatServer {
* the channel username (parallels forwardToGlobal but keyed by user id since
* the channel username (parallels forwardToGlobal but keyed by user id since
* there's no live session id to resolve).
* there's no live session id to resolve).
*/
*/
- forwardToGlobalByChannel(channelUserId, data) {
+ async forwardToGlobalByChannel(channelUserId, data) {
if (!channelUserId) return;
if (!channelUserId) return;
let username = null;
let username = null;
- try { username = ctx.getUserById(channelUserId)?.username; } catch { /* ignore */ }
+ try { username = (await ctx.getUserById(channelUserId))?.username; } catch { /* ignore */ }
if (!username) return;
if (!username) return;
const globalMsg = JSON.stringify({ ...data, stream_channel: username, source_channel: username });
const globalMsg = JSON.stringify({ ...data, stream_channel: username, source_channel: username });
- const blockers = this._blockersOf(data);
+ const blockers = await this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
+ if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {
ws.send(globalMsg);
ws.send(globalMsg);
}
}
}
}
}
}
- broadcastGlobal(data) {
+ async broadcastGlobal(data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
- const blockers = this._blockersOf(data);
+ const blockers = await this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
// Pure global clients only (see forwardToGlobal) — channel/stream viewers get
// Pure global clients only (see forwardToGlobal) — channel/stream viewers get
// global activity via their dedicated cross-feed socket, not their main one.
// global activity via their dedicated cross-feed socket, not their main one.
- if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
+ if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {
ws.send(msg);
ws.send(msg);
}
}
}
}
@@ -2436,12 +2436,12 @@ class ChatServer {
@@ -2436,12 +2436,12 @@ class ChatServer {
* A room's roles or visibility changed: every socket following it learns what it may do now
* A room's roles or visibility changed: every socket following it learns what it may do now
* (`room_access`), and one that may no longer read it leaves (`room_left`). → sockets told
* (`room_access`), and one that may no longer read it leaves (`room_left`). → sockets told
*/
*/
- refreshRoomAccess(room) {
+ async refreshRoomAccess(room) {
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
let n = 0;
let n = 0;
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
if (!room || client.roomId !== room.id) continue;
if (!room || client.roomId !== room.id) continue;
- const a = rooms.access(room, client.user || null);
+ const a = await rooms.access(room, client.user || null);
n++;
n++;
if (!a.read) { client.roomId = null; client.roomSlug = null; this.sendTo(ws, { type: 'room_left', reason: 'removed' }); continue; }
if (!a.read) { client.roomId = null; client.roomSlug = null; this.sendTo(ws, { type: 'room_left', reason: 'removed' }); continue; }
this.sendTo(ws, { type: 'room_access', room: room.slug, kind: a.kind, role: a.role, can: { post: a.post, moderate: a.moderate, manage: a.manage, join: a.join, talk: a.talk } });
this.sendTo(ws, { type: 'room_access', room: room.slug, kind: a.kind, role: a.role, can: { post: a.post, moderate: a.moderate, manage: a.manage, join: a.join, talk: a.talk } });
@@ -2466,9 +2466,9 @@ class ChatServer {
@@ -2466,9 +2466,9 @@ class ChatServer {
* channel's emotes/sounds changed, so their chat pickers refresh live
* channel's emotes/sounds changed, so their chat pickers refresh live
* (no page reload). No-op when the owner has no live streams / viewers.
* (no page reload). No-op when the owner has no live streams / viewers.
*/
*/
- broadcastToOwnerStreams(ownerUserId, data) {
+ async broadcastToOwnerStreams(ownerUserId, data) {
try {
try {
- const streams = ctx.getLiveStreamsByUserId(ownerUserId) || [];
+ const streams = await ctx.getLiveStreamsByUserId(ownerUserId) || [];
if (!streams.length) return;
if (!streams.length) return;
const ids = new Set(streams.map(s => String(s.id)));
const ids = new Set(streams.map(s => String(s.id)));
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
@@ -2484,13 +2484,13 @@ class ChatServer {
@@ -2484,13 +2484,13 @@ class ChatServer {
* Forward a stream message to all global-connected clients
* Forward a stream message to all global-connected clients
* so the global chat feed shows activity from every stream.
* so the global chat feed shows activity from every stream.
*/
*/
- forwardToGlobal(streamId, data) {
+ async forwardToGlobal(streamId, data) {
// Look up stream owner username + slot title/slug (cached per stream)
// Look up stream owner username + slot title/slug (cached per stream)
if (!this._streamNameCache) this._streamNameCache = new Map();
if (!this._streamNameCache) this._streamNameCache = new Map();
let info = this._streamNameCache.get(streamId);
let info = this._streamNameCache.get(streamId);
if (!info || typeof info !== 'object') {
if (!info || typeof info !== 'object') {
try {
try {
- const stream = ctx.getStreamById(streamId);
+ const stream = await ctx.getStreamById(streamId);
info = {
info = {
username: stream?.username || `stream-${streamId}`,
username: stream?.username || `stream-${streamId}`,
title: stream?.managed_stream_title || stream?.title || null,
title: stream?.managed_stream_title || stream?.title || null,
@@ -2514,9 +2514,9 @@ class ChatServer {
@@ -2514,9 +2514,9 @@ class ChatServer {
source_managed_id: info.managedId,
source_managed_id: info.managedId,
source_is_live: 1, // this path only fires for a live send
source_is_live: 1, // this path only fires for a live send
});
});
- const blockers = this._blockersOf(data);
+ const blockers = await this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
- if (this._blockedFor(client, blockers)) continue;
+ if (await this._blockedFor(client, blockers)) continue;
// Only PURE global clients (no stream AND no channel) — otherwise an
// Only PURE global clients (no stream AND no channel) — otherwise an
// offline-channel viewer's main socket (streamId null, channelUserId set)
// offline-channel viewer's main socket (streamId null, channelUserId set)
// would render this via addChatMessage AND get it again as a cross-feed on
// would render this via addChatMessage AND get it again as a cross-feed on
@@ -2534,11 +2534,11 @@ class ChatServer {
@@ -2534,11 +2534,11 @@ class ChatServer {
* client can show a stream-title badge + let viewers hop between slots. Only
* client can show a stream-title badge + let viewers hop between slots. Only
* fires when the streamer has 2+ live slots.
* fires when the streamer has 2+ live slots.
*/
*/
- forwardToStreamerRooms(streamId, data) {
+ async forwardToStreamerRooms(streamId, data) {
try {
try {
- const stream = ctx.getStreamById(streamId);
+ const stream = await ctx.getStreamById(streamId);
if (!stream || !stream.user_id) return;
if (!stream || !stream.user_id) return;
- const siblings = ctx.getLiveStreamsByUserId(stream.user_id) || [];
+ const siblings = await ctx.getLiveStreamsByUserId(stream.user_id) || [];
if (siblings.length < 2) return;
if (siblings.length < 2) return;
const siblingIds = new Set(siblings.map(s => s.id));
const siblingIds = new Set(siblings.map(s => s.id));
const payload = JSON.stringify({
const payload = JSON.stringify({
@@ -2551,34 +2551,34 @@ class ChatServer {
@@ -2551,34 +2551,34 @@ class ChatServer {
source_channel: stream.username || null,
source_channel: stream.username || null,
source_is_live: 1,
source_is_live: 1,
});
});
- const blockers = this._blockersOf(data);
+ const blockers = await this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
if (client.streamId && client.streamId !== streamId && siblingIds.has(client.streamId)
if (client.streamId && client.streamId !== streamId && siblingIds.has(client.streamId)
- && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
+ && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {
ws.send(payload);
ws.send(payload);
}
}
}
}
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
- _broadcastDeletedMessages(streamId, ids) {
+ async _broadcastDeletedMessages(streamId, ids) {
if (!Array.isArray(ids) || ids.length === 0) return;
if (!Array.isArray(ids) || ids.length === 0) return;
const payload = { type: 'delete-messages', ids };
const payload = { type: 'delete-messages', ids };
if (streamId) {
if (streamId) {
// The stream's room, the rest of its channel room (other slots, the offline room, a
// The stream's room, the rest of its channel room (other slots, the offline room, a
// channel popout) and the global feed: everywhere the lines were shown.
// channel popout) and the global feed: everywhere the lines were shown.
let ownerId = null;
let ownerId = null;
- try { ownerId = ctx.getStreamById(streamId)?.user_id || null; } catch { ownerId = null; }
- this.broadcastToChannelRoom(ownerId, streamId, payload);
- this.forwardToGlobal(streamId, payload);
+ try { ownerId = (await ctx.getStreamById(streamId))?.user_id || null; } catch { ownerId = null; }
+ await this.broadcastToChannelRoom(ownerId, streamId, payload);
+ await this.forwardToGlobal(streamId, payload);
return;
return;
}
}
- this.broadcastAll(payload);
+ await this.broadcastAll(payload);
}
}
- _sweepExpiredChatMessages() {
+ async _sweepExpiredChatMessages() {
try {
try {
- const expired = db.deleteExpiredChatMessages(500);
+ const expired = await db.deleteExpiredChatMessages(500);
if (!expired.length) return;
if (!expired.length) return;
const byScope = new Map();
const byScope = new Map();
for (const row of expired) {
for (const row of expired) {
@@ -2588,7 +2588,7 @@ class ChatServer {
@@ -2588,7 +2588,7 @@ class ChatServer {
}
}
for (const [key, ids] of byScope.entries()) {
for (const [key, ids] of byScope.entries()) {
const streamId = key === 'global' ? null : parseInt(key.split(':')[1], 10);
const streamId = key === 'global' ? null : parseInt(key.split(':')[1], 10);
- this._broadcastDeletedMessages(streamId, ids);
+ await this._broadcastDeletedMessages(streamId, ids);
}
}
} catch (err) {
} catch (err) {
console.warn('[Chat] Failed to sweep expired auto-delete messages:', err.message);
console.warn('[Chat] Failed to sweep expired auto-delete messages:', err.message);
@@ -2613,7 +2613,7 @@ class ChatServer {
@@ -2613,7 +2613,7 @@ class ChatServer {
* Record viewer snapshots for all active streams.
* Record viewer snapshots for all active streams.
* Called every 60 seconds by the snapshot interval timer.
* Called every 60 seconds by the snapshot interval timer.
*/
*/
- _recordViewerSnapshots() {
+ async _recordViewerSnapshots() {
// Collect unique active stream IDs
// Collect unique active stream IDs
const streamIds = new Set();
const streamIds = new Set();
for (const [, client] of this.clients) {
for (const [, client] of this.clients) {
@@ -2622,7 +2622,7 @@ class ChatServer {
@@ -2622,7 +2622,7 @@ class ChatServer {
for (const streamId of streamIds) {
for (const streamId of streamIds) {
try {
try {
const count = this.getStreamViewerCount(streamId);
const count = this.getStreamViewerCount(streamId);
- const chatActivity = db.getRecentChatActivity(streamId, 5);
+ const chatActivity = await db.getRecentChatActivity(streamId, 5);
ctx.effects.viewerSnapshot(streamId, count, chatActivity);
ctx.effects.viewerSnapshot(streamId, count, chatActivity);
} catch (err) {
} catch (err) {
// Non-critical — don't crash the chat server over analytics
// Non-critical — don't crash the chat server over analytics
@@ -2638,9 +2638,9 @@ class ChatServer {
@@ -2638,9 +2638,9 @@ class ChatServer {
const key = `users-${streamId ?? 'global'}`;
const key = `users-${streamId ?? 'global'}`;
if (this._usersListTimers?.has(key)) return; // already scheduled
if (this._usersListTimers?.has(key)) return; // already scheduled
if (!this._usersListTimers) this._usersListTimers = new Map();
if (!this._usersListTimers) this._usersListTimers = new Map();
- this._usersListTimers.set(key, setTimeout(() => {
+ this._usersListTimers.set(key, setTimeout(async () => {
this._usersListTimers.delete(key);
this._usersListTimers.delete(key);
- const users = this.getUserList(streamId);
+ const users = await this.getUserList(streamId);
const data = JSON.stringify({ type: 'users-list', users });
const data = JSON.stringify({ type: 'users-list', users });
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
if (client.streamId === streamId && ws.readyState === WebSocket.OPEN) {
if (client.streamId === streamId && ws.readyState === WebSocket.OPEN) {
@@ -2672,9 +2672,9 @@ class ChatServer {
@@ -2672,9 +2672,9 @@ class ChatServer {
* Send a DM payload to all WebSocket connections belonging to a given user ID.
* Send a DM payload to all WebSocket connections belonging to a given user ID.
* Used by the DM REST API for real-time delivery.
* Used by the DM REST API for real-time delivery.
*/
*/
- sendDm(userId, data) {
+ async sendDm(userId, data) {
const convId = data?.conversation_id;
const convId = data?.conversation_id;
- if (convId && !dm.isParticipant(convId, userId)) {
+ if (convId && !await dm.isParticipant(convId, userId)) {
if (DEBUG_DM_DELIVERY) {
if (DEBUG_DM_DELIVERY) {
console.warn(`[DM] blocked delivery to user ${userId} for conversation ${convId} (not a participant)`, data.type, data);
console.warn(`[DM] blocked delivery to user ${userId} for conversation ${convId} (not a participant)`, data.type, data);
}
}
@@ -2718,19 +2718,19 @@ class ChatServer {
@@ -2718,19 +2718,19 @@ class ChatServer {
* Push a profile/identity update to all chat connections belonging to a user.
* Push a profile/identity update to all chat connections belonging to a user.
* Refreshes cached client.user so subsequent messages use the new info.
* Refreshes cached client.user so subsequent messages use the new info.
*/
*/
- sendUserUpdate(userId, userData) {
+ async sendUserUpdate(userId, userData) {
// Live changed the account (admin edit): refresh the projection first. A new name is
// Live changed the account (admin edit): refresh the projection first. A new name is
// also rewritten into this user's stored chat lines, as Live did with its own rows.
// also rewritten into this user's stored chat lines, as Live did with its own rows.
- const before = ctx.getUserById(userId);
+ const before = await ctx.getUserById(userId);
if (userData && userData.id != null) {
if (userData && userData.id != null) {
- ctx.upsertUser({ ...(before || {}), ...userData });
+ await ctx.upsertUser({ ...(before || {}), ...userData });
const newChatName = userData.display_name || userData.username;
const newChatName = userData.display_name || userData.username;
if (before && newChatName && newChatName !== (before.display_name || before.username)) {
if (before && newChatName && newChatName !== (before.display_name || before.username)) {
- try { db.renameUserChatMessages(userId, newChatName); } catch { /* non-critical */ }
+ try { await db.renameUserChatMessages(userId, newChatName); } catch { /* non-critical */ }
}
}
}
}
ctx.invalidateUser(userId);
ctx.invalidateUser(userId);
- const freshUser = ctx.getUserById(userId);
+ const freshUser = await ctx.getUserById(userId);
const payload = JSON.stringify({
const payload = JSON.stringify({
type: 'user-updated',
type: 'user-updated',
user: {
user: {
@@ -2757,11 +2757,11 @@ class ChatServer {
@@ -2757,11 +2757,11 @@ class ChatServer {
* Broadcast a message to ALL connected chat clients (every stream + global).
* Broadcast a message to ALL connected chat clients (every stream + global).
* Used for server-wide announcements (restarts, updates).
* Used for server-wide announcements (restarts, updates).
*/
*/
- broadcastAll(data) {
+ async broadcastAll(data) {
const msg = JSON.stringify(data);
const msg = JSON.stringify(data);
- const blockers = this._blockersOf(data);
+ const blockers = await this._blockersOf(data);
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
- if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !this._blockedFor(client, blockers)) {
+ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE && !await this._blockedFor(client, blockers)) {
ws.send(msg);
ws.send(msg);
}
}
}
}
@@ -2811,24 +2811,24 @@ class ChatServer {
@@ -2811,24 +2811,24 @@ class ChatServer {
* A room's slow mode in ms: its channel's saved slow_mode_seconds (channel moderation settings,
* A room's slow mode in ms: its channel's saved slow_mode_seconds (channel moderation settings,
* the dashboard's value and /slow's). Read from the policy cache, so a restart keeps it.
* the dashboard's value and /slow's). Read from the policy cache, so a restart keeps it.
*/
*/
- slowModeMs(streamId) {
+ async slowModeMs(streamId) {
if (!streamId) return 0;
if (!streamId) return 0;
- return Math.max(0, parseInt(this._getChannelChatSettings(streamId).slow_mode_seconds, 10) || 0) * 1000;
+ return Math.max(0, parseInt((await this._getChannelChatSettings(streamId)).slow_mode_seconds, 10) || 0) * 1000;
}
}
/** streamId → slow mode ms for the streams with sockets here (Live's presence read). */
/** streamId → slow mode ms for the streams with sockets here (Live's presence read). */
- get slowModeByStream() {
+ async slowModeByStream() {
const out = new Map();
const out = new Map();
- for (const [, c] of this.clients) if (c.streamId && !out.has(c.streamId)) out.set(c.streamId, this.slowModeMs(c.streamId));
+ for (const [, c] of this.clients) if (c.streamId && !out.has(c.streamId)) out.set(c.streamId, await this.slowModeMs(c.streamId));
return out;
return out;
}
}
/** The channel a client's room belongs to: { channel, ownerId } or null (global chat). */
/** The channel a client's room belongs to: { channel, ownerId } or null (global chat). */
async _channelOfRoom(client) {
async _channelOfRoom(client) {
- const modStreamId = client.streamId || this._moderationStreamFor(client);
- const stream = modStreamId ? ctx.getStreamById(modStreamId) : null;
+ const modStreamId = client.streamId || await this._moderationStreamFor(client);
+ const stream = modStreamId ? await ctx.getStreamById(modStreamId) : null;
const ownerId = (stream && stream.user_id) || client.channelUserId || null;
const ownerId = (stream && stream.user_id) || client.channelUserId || null;
- let channel = stream && stream.channel_id ? ctx.getChannelById(stream.channel_id) : null;
+ let channel = stream && stream.channel_id ? await ctx.getChannelById(stream.channel_id) : null;
if (!channel && ownerId) channel = await ctx.ensureChannelForUser(ownerId);
if (!channel && ownerId) channel = await ctx.ensureChannelForUser(ownerId);
return channel ? { channel, ownerId: channel.user_id || ownerId } : null;
return channel ? { channel, ownerId: channel.user_id || ownerId } : null;
}
}
@@ -2860,26 +2860,26 @@ class ChatServer {
@@ -2860,26 +2860,26 @@ class ChatServer {
const n = (this._modeWrites.get(id) || 1) - 1;
const n = (this._modeWrites.get(id) || 1) - 1;
if (n > 0) this._modeWrites.set(id, n); else this._modeWrites.delete(id);
if (n > 0) this._modeWrites.set(id, n); else this._modeWrites.delete(id);
}
}
- const next = this._modesOf(ctx.getChannelModerationSettings(id));
+ const next = this._modesOf(await ctx.getChannelModerationSettings(id));
if (fields.slow_mode_seconds !== undefined) next.slow = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);
if (fields.slow_mode_seconds !== undefined) next.slow = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);
if (fields.sub_only !== undefined) next.sub = fields.sub_only ? 1 : 0;
if (fields.sub_only !== undefined) next.sub = fields.sub_only ? 1 : 0;
this._announcedModes.set(id, next);
this._announcedModes.set(id, next);
// A command is always answered in the room, even when the value did not change.
// A command is always answered in the room, even when the value did not change.
- this._announceModes(room.ownerId, client.streamId, { slow: fields.slow_mode_seconds !== undefined ? -1 : next.slow, sub: fields.sub_only !== undefined ? -1 : next.sub }, next);
+ await this._announceModes(room.ownerId, client.streamId, { slow: fields.slow_mode_seconds !== undefined ? -1 : next.slow, sub: fields.sub_only !== undefined ? -1 : next.sub }, next);
return true;
return true;
}
}
/** Tell a channel's room what changed: the slowmode / subonly frame and a system line for each. */
/** Tell a channel's room what changed: the slowmode / subonly frame and a system line for each. */
- _announceModes(ownerUserId, streamId, prev, next) {
+ async _announceModes(ownerUserId, streamId, prev, next) {
if (!ownerUserId && !streamId) return;
if (!ownerUserId && !streamId) return;
- const say = (frame) => this.broadcastToChannelRoom(ownerUserId || null, streamId || null, frame);
+ const say = async (frame) => await this.broadcastToChannelRoom(ownerUserId || null, streamId || null, frame);
if (prev.slow !== next.slow) {
if (prev.slow !== next.slow) {
- say({ type: 'slowmode', seconds: next.slow });
- say({ type: 'system', message: next.slow > 0 ? `Slow mode enabled: ${next.slow}s between messages` : 'Slow mode disabled.' });
+ await say({ type: 'slowmode', seconds: next.slow });
+ await say({ type: 'system', message: next.slow > 0 ? `Slow mode enabled: ${next.slow}s between messages` : 'Slow mode disabled.' });
}
}
if (prev.sub !== next.sub) {
if (prev.sub !== next.sub) {
- say({ type: 'subonly', enabled: !!next.sub });
- say({ type: 'system', message: next.sub ? 'Sub-only mode enabled: only subscribers and moderators can chat.' : 'Sub-only mode disabled.' });
+ await say({ type: 'subonly', enabled: !!next.sub });
+ await say({ type: 'system', message: next.sub ? 'Sub-only mode enabled: only subscribers and moderators can chat.' : 'Sub-only mode disabled.' });
}
}
}
}
@@ -2888,22 +2888,22 @@ class ChatServer {
@@ -2888,22 +2888,22 @@ class ChatServer {
* sub-only mode differs from what its room was last told (the dashboard changed it), tell the
* sub-only mode differs from what its room was last told (the dashboard changed it), tell the
* room. The first read after a start only records the values.
* room. The first read after a start only records the values.
*/
*/
- _channelSettingsSeen(channelId, settings) {
+ async _channelSettingsSeen(channelId, settings) {
const id = Number(channelId);
const id = Number(channelId);
if (!id || this._modeWrites.has(id)) return;
if (!id || this._modeWrites.has(id)) return;
const next = this._modesOf(settings);
const next = this._modesOf(settings);
const prev = this._announcedModes.get(id);
const prev = this._announcedModes.get(id);
this._announcedModes.set(id, next);
this._announcedModes.set(id, next);
if (!prev || (prev.slow === next.slow && prev.sub === next.sub)) return;
if (!prev || (prev.slow === next.slow && prev.sub === next.sub)) return;
- const channel = ctx.getChannelById(id);
- if (channel && channel.user_id) this._announceModes(channel.user_id, null, prev, next);
+ const channel = await ctx.getChannelById(id);
+ if (channel && channel.user_id) await this._announceModes(channel.user_id, null, prev, next);
}
}
/**
/**
* Get channel moderation settings for a stream.
* Get channel moderation settings for a stream.
* Caches the channel lookup to avoid repeated DB queries.
* Caches the channel lookup to avoid repeated DB queries.
*/
*/
- _getChannelChatSettings(streamId) {
+ async _getChannelChatSettings(streamId) {
const defaults = {
const defaults = {
slow_mode_seconds: 0, followers_only: 0, emote_only: 0,
slow_mode_seconds: 0, followers_only: 0, emote_only: 0,
allow_anonymous: 1, links_allowed: 1, account_age_gate_hours: 0,
allow_anonymous: 1, links_allowed: 1, account_age_gate_hours: 0,
@@ -2935,11 +2935,11 @@ class ChatServer {
@@ -2935,11 +2935,11 @@ class ChatServer {
});
});
if (!streamId) return finalize(defaults);
if (!streamId) return finalize(defaults);
try {
try {
- const stream = ctx.getStreamById(streamId);
+ const stream = await ctx.getStreamById(streamId);
if (!stream) return finalize(defaults);
if (!stream) return finalize(defaults);
- const channel = stream.channel_id ? ctx.getChannelById(stream.channel_id) : ctx.getChannelByUserId(stream.user_id);
+ const channel = stream.channel_id ? await ctx.getChannelById(stream.channel_id) : await ctx.getChannelByUserId(stream.user_id);
if (!channel) return finalize(defaults);
if (!channel) return finalize(defaults);
- return finalize({ ...defaults, ...ctx.getChannelModerationSettings(channel.id) });
+ return finalize({ ...defaults, ...await ctx.getChannelModerationSettings(channel.id) });
} catch {
} catch {
return finalize(defaults);
return finalize(defaults);
}
}
@@ -2949,13 +2949,13 @@ class ChatServer {
@@ -2949,13 +2949,13 @@ class ChatServer {
* Log a moderation action from a chat command (/ban, /timeout, /clear, /slowmode).
* Log a moderation action from a chat command (/ban, /timeout, /clear, /slowmode).
* Non-critical — failures are silently ignored.
* Non-critical — failures are silently ignored.
*/
*/
- logChatModeration(client, actionType, details = {}, targetUserId = null) {
+ async logChatModeration(client, actionType, details = {}, targetUserId = null) {
try {
try {
- const stream = client.streamId ? ctx.getStreamById(client.streamId) : null;
+ const stream = client.streamId ? await ctx.getStreamById(client.streamId) : null;
const channel = stream?.channel_id
const channel = stream?.channel_id
- ? ctx.getChannelById(stream.channel_id)
- : stream ? ctx.getChannelByUserId(stream.user_id) : null;
- db.logModerationAction({
+ ? await ctx.getChannelById(stream.channel_id)
+ : stream ? await ctx.getChannelByUserId(stream.user_id) : null;
+ await db.logModerationAction({
scope_type: channel ? 'channel' : 'site',
scope_type: channel ? 'channel' : 'site',
scope_id: channel?.id || undefined,
scope_id: channel?.id || undefined,
actor_user_id: client.user?.id || undefined,
actor_user_id: client.user?.id || undefined,
@@ -57,9 +57,9 @@ const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.le
@@ -57,9 +57,9 @@ const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.le
* that kept folding while people chatted in a stream ended up with a time range running past the
* that kept folding while people chatted in a stream ended up with a time range running past the
* messages under it. Anyone speaking anywhere since the last card starts a new card.
* messages under it. Anyone speaking anywhere since the last card starts a new card.
*/
*/
-function persist(db, commits) {
+async function persist(db, commits) {
const nowIso = new Date().toISOString();
const nowIso = new Date().toISOString();
- const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');
+ const newest = await db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');
let prev = null;
let prev = null;
if (newest && newest.message_type === 'system' && newest.metadata) {
if (newest && newest.message_type === 'system' && newest.metadata) {
try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }
try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }
@@ -68,11 +68,11 @@ function persist(db, commits) {
@@ -68,11 +68,11 @@ function persist(db, commits) {
const seen = new Set(commits.map(c => c.hash));
const seen = new Set(commits.map(c => c.hash));
const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);
const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);
const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };
const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };
- db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);
+ await db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);
return { id: newest.id, meta, folded: true };
return { id: newest.id, meta, folded: true };
}
}
const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };
const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };
- const res = db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });
+ const res = await db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });
return { id: Number(res && (res.lastInsertRowid || res.lastID || res.id)) || null, meta, folded: false };
return { id: Number(res && (res.lastInsertRowid || res.lastID || res.id)) || null, meta, folded: false };
}
}
@@ -81,16 +81,16 @@ function persist(db, commits) {
@@ -81,16 +81,16 @@ function persist(db, commits) {
* to arrive makes the card; the other finds the head claimed, notes when it saw it, and changes
* to arrive makes the card; the other finds the head claimed, notes when it saw it, and changes
* nothing else. Returns { duplicate: true, id } or { duplicate: false, saved }.
* nothing else. Returns { duplicate: true, id } or { duplicate: false, saved }.
*/
*/
-function claimRelease(db, { head, commits, via, eventId = null, now = Date.now() }) {
+async function claimRelease(db, { head, commits, via, eventId = null, now = Date.now() }) {
const seenCol = via === 'events' ? 'event_at' : 'bridge_at';
const seenCol = via === 'events' ? 'event_at' : 'bridge_at';
- return db.transaction(() => {
- const claimed = db.get('SELECT message_id FROM deploy_releases WHERE head = ?', [head]);
+ return await db.tx(async () => {
+ const claimed = await db.get('SELECT message_id FROM deploy_releases WHERE head = ?', [head]);
if (claimed) {
if (claimed) {
- db.run(`UPDATE deploy_releases SET ${seenCol} = COALESCE(${seenCol}, ?), event_id = COALESCE(event_id, ?) WHERE head = ?`, [now, eventId, head]);
+ await db.run(`UPDATE deploy_releases SET ${seenCol} = COALESCE(${seenCol}, ?), event_id = COALESCE(event_id, ?) WHERE head = ?`, [now, eventId, head]);
return { duplicate: true, id: claimed.message_id };
return { duplicate: true, id: claimed.message_id };
}
}
- const saved = persist(db, commits);
- db.run(`INSERT INTO deploy_releases (head, message_id, first_via, ${seenCol}, event_id, commit_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ const saved = await persist(db, commits);
+ await db.run(`INSERT INTO deploy_releases (head, message_id, first_via, ${seenCol}, event_id, commit_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[head, saved.id, via, now, eventId, commits.length, now]);
[head, saved.id, via, now, eventId, commits.length, now]);
return { duplicate: false, saved };
return { duplicate: false, saved };
});
});
@@ -118,13 +118,13 @@ function broadcast({ chatServer, saved, list, log = console }) {
@@ -118,13 +118,13 @@ function broadcast({ chatServer, saved, list, log = console }) {
* bridge call) announces nothing.
* bridge call) announces nothing.
* @returns {{ announced: number, id?: number, duplicate?: boolean }}
* @returns {{ announced: number, id?: number, duplicate?: boolean }}
*/
*/
-function announceCommits({ db, chatServer, commits, log = console }) {
+async function announceCommits({ db, chatServer, commits, log = console }) {
const list = cleanCommits(commits);
const list = cleanCommits(commits);
if (!list.length) return { announced: 0 };
if (!list.length) return { announced: 0 };
const head = list[0].hash;
const head = list[0].hash;
let r;
let r;
- try { r = claimRelease(db, { head, commits: list, via: 'bridge' }); }
+ try { r = await claimRelease(db, { head, commits: list, via: 'bridge' }); }
catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
if (r.duplicate) {
if (r.duplicate) {
log.log(`[Deploy notice] ${head.slice(0, 7)} already announced (card ${r.id}); the bridge copy changes nothing`);
log.log(`[Deploy notice] ${head.slice(0, 7)} already announced (card ${r.id}); the bridge copy changes nothing`);
@@ -155,10 +155,10 @@ function releaseFrom(event, { now = Date.now(), maxAgeMs = RELEASE_MAX_AGE_MS }
@@ -155,10 +155,10 @@ function releaseFrom(event, { now = Date.now(), maxAgeMs = RELEASE_MAX_AGE_MS }
* has committed. outcome: announced (a new card) | folded (into the last card) | duplicate:release
* has committed. outcome: announced (a new card) | folded (into the last card) | duplicate:release
* (the bridge, or an earlier event with this head, made the card already).
* (the bridge, or an earlier event with this head, made the card already).
*/
*/
-function applyReleaseEvent({ db, chatServer, event, now = Date.now(), maxAgeMs = RELEASE_MAX_AGE_MS, log = console }) {
+async function applyReleaseEvent({ db, chatServer, event, now = Date.now(), maxAgeMs = RELEASE_MAX_AGE_MS, log = console }) {
const rel = releaseFrom(event, { now, maxAgeMs });
const rel = releaseFrom(event, { now, maxAgeMs });
if (typeof rel === 'string') return rel;
if (typeof rel === 'string') return rel;
- const r = claimRelease(db, { head: rel.head, commits: rel.commits, via: 'events', eventId: event.event_id, now });
+ const r = await claimRelease(db, { head: rel.head, commits: rel.commits, via: 'events', eventId: event.event_id, now });
const detail = { head: rel.head, message_id: r.duplicate ? r.id : r.saved.id, commits: rel.commits.length };
const detail = { head: rel.head, message_id: r.duplicate ? r.id : r.saved.id, commits: rel.commits.length };
if (r.duplicate) return { outcome: 'duplicate:release', detail };
if (r.duplicate) return { outcome: 'duplicate:release', detail };
return { outcome: r.saved.folded ? 'folded' : 'announced', detail, after: () => broadcast({ chatServer, saved: r.saved, list: rel.commits, log }) };
return { outcome: r.saved.folded ? 'folded' : 'announced', detail, after: () => broadcast({ chatServer, saved: r.saved, list: rel.commits, log }) };
@@ -26,8 +26,8 @@ const prefStores = require('../prefs/stores');
@@ -26,8 +26,8 @@ const prefStores = require('../prefs/stores');
* conversation, or being added to a group? When Network cannot answer, the defaults (yes) apply.
* conversation, or being added to a group? When Network cannot answer, the defaults (yes) apply.
*/
*/
async function dmSettingsOf(target) {
async function dmSettingsOf(target) {
- const subject = (target && target.subject_id) || require('../db/database').subjectFor(target && target.id);
- return prefStores.settingsOf(prefStores.dm, subject);
+ const subject = (target && target.subject_id) || await require('../db/database').subjectFor(target && target.id);
+ return await prefStores.settingsOf(prefStores.dm, subject);
}
}
// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.
// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.
@@ -78,12 +78,12 @@ setInterval(() => {
@@ -78,12 +78,12 @@ setInterval(() => {
}, 300_000);
}, 300_000);
// List conversations for current user
// List conversations for current user
-router.get('/conversations', (req, res) => {
+router.get('/conversations', async (req, res) => {
try {
try {
- const conversations = dm.getConversations(req.user.id);
+ const conversations = await dm.getConversations(req.user.id);
// Attach participant info to each conversation
// Attach participant info to each conversation
for (const conv of conversations) {
for (const conv of conversations) {
- conv.participants = dm.getParticipants(conv.id);
+ conv.participants = await dm.getParticipants(conv.id);
}
}
res.json({ conversations });
res.json({ conversations });
} catch (err) {
} catch (err) {
@@ -113,7 +113,7 @@ router.post('/conversations', startLimit, async (req, res) => {
@@ -113,7 +113,7 @@ router.post('/conversations', startLimit, async (req, res) => {
}
}
// New account restriction
// New account restriction
- const accountCheck = dm.isAccountTooNew(req.user.id, 5);
+ const accountCheck = await dm.isAccountTooNew(req.user.id, 5);
if (accountCheck.tooNew) {
if (accountCheck.tooNew) {
return res.status(403).json({ error: `Your account is too new to start conversations. Please wait ${accountCheck.minutesRemaining} more minute(s).` });
return res.status(403).json({ error: `Your account is too new to start conversations. Please wait ${accountCheck.minutesRemaining} more minute(s).` });
}
}
@@ -122,15 +122,15 @@ router.post('/conversations', startLimit, async (req, res) => {
@@ -122,15 +122,15 @@ router.post('/conversations', startLimit, async (req, res) => {
try { await ctx.ensureUsers(participantIds); } catch { /* the projection answers below */ }
try { await ctx.ensureUsers(participantIds); } catch { /* the projection answers below */ }
for (const uid of participantIds) {
for (const uid of participantIds) {
if (uid === req.user.id) continue;
if (uid === req.user.id) continue;
- const target = ctx.getUserById(uid);
+ const target = await ctx.getUserById(uid);
if (!target) return res.status(400).json({ error: 'User not found' });
if (!target) return res.status(400).json({ error: 'User not found' });
if (target.is_banned) return res.status(400).json({ error: 'Cannot message banned users' });
if (target.is_banned) return res.status(400).json({ error: 'Cannot message banned users' });
- if (dm.isBlockedEither(req.user.id, uid)) {
+ if (await dm.isBlockedEither(req.user.id, uid)) {
return res.status(403).json({ error: 'Cannot start a conversation with this user' });
return res.status(403).json({ error: 'Cannot start a conversation with this user' });
}
}
// Their message settings: a new direct conversation, or a group, only if they accept one.
// Their message settings: a new direct conversation, or a group, only if they accept one.
const wants = await dmSettingsOf(target);
const wants = await dmSettingsOf(target);
- if (participantIds.length === 2 && wants.new_conversations === 'nobody' && !dm.findDirectConversation(req.user.id, uid)) {
+ if (participantIds.length === 2 && wants.new_conversations === 'nobody' && !await dm.findDirectConversation(req.user.id, uid)) {
return res.status(403).json({ error: `${target.display_name || target.username} is not accepting new conversations`, code: 'dm.not_accepting' });
return res.status(403).json({ error: `${target.display_name || target.username} is not accepting new conversations`, code: 'dm.not_accepting' });
}
}
if (participantIds.length > 2 && wants.group_invites === false) {
if (participantIds.length > 2 && wants.group_invites === false) {
@@ -141,14 +141,14 @@ router.post('/conversations', startLimit, async (req, res) => {
@@ -141,14 +141,14 @@ router.post('/conversations', startLimit, async (req, res) => {
let conversationId;
let conversationId;
if (participantIds.length === 2) {
if (participantIds.length === 2) {
const otherId = participantIds.find(id => id !== req.user.id);
const otherId = participantIds.find(id => id !== req.user.id);
- conversationId = dm.getOrCreateDirect(req.user.id, otherId);
+ conversationId = await dm.getOrCreateDirect(req.user.id, otherId);
} else {
} else {
const safeName = name ? String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100) : null;
const safeName = name ? String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100) : null;
- conversationId = dm.createConversation(req.user.id, participantIds, safeName);
+ conversationId = await dm.createConversation(req.user.id, participantIds, safeName);
}
}
- const conversation = dm.getConversation(conversationId);
- conversation.participants = dm.getParticipants(conversationId);
+ const conversation = await dm.getConversation(conversationId);
+ conversation.participants = await dm.getParticipants(conversationId);
res.json({ conversation });
res.json({ conversation });
} catch (err) {
} catch (err) {
console.error('[DM] Create conversation error:', err.message);
console.error('[DM] Create conversation error:', err.message);
@@ -157,15 +157,15 @@ router.post('/conversations', startLimit, async (req, res) => {
@@ -157,15 +157,15 @@ router.post('/conversations', startLimit, async (req, res) => {
});
});
// Get conversation details
// Get conversation details
-router.get('/conversations/:id', (req, res) => {
+router.get('/conversations/:id', async (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
+ if (!await dm.isParticipant(convId, req.user.id)) {
return res.status(403).json({ error: 'Not a participant' });
return res.status(403).json({ error: 'Not a participant' });
}
}
- const conversation = dm.getConversation(convId);
+ const conversation = await dm.getConversation(convId);
if (!conversation) return res.status(404).json({ error: 'Not found' });
if (!conversation) return res.status(404).json({ error: 'Not found' });
- conversation.participants = dm.getParticipants(convId);
+ conversation.participants = await dm.getParticipants(convId);
res.json({ conversation });
res.json({ conversation });
} catch (err) {
} catch (err) {
console.error('[DM] Get conversation error:', err.message);
console.error('[DM] Get conversation error:', err.message);
@@ -174,16 +174,16 @@ router.get('/conversations/:id', (req, res) => {
@@ -174,16 +174,16 @@ router.get('/conversations/:id', (req, res) => {
});
});
// Get messages (paginated)
// Get messages (paginated)
-router.get('/conversations/:id/messages', (req, res) => {
+router.get('/conversations/:id/messages', async (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
+ if (!await dm.isParticipant(convId, req.user.id)) {
return res.status(403).json({ error: 'Not a participant' });
return res.status(403).json({ error: 'Not a participant' });
}
}
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
const before = parseInt(req.query.before) || null;
const before = parseInt(req.query.before) || null;
const after = parseInt(req.query.after) || null;
const after = parseInt(req.query.after) || null;
- const messages = dm.getMessages(convId, limit, before, after).map(publicMessage);
+ const messages = (await dm.getMessages(convId, limit, before, after)).map(publicMessage);
res.json({ messages });
res.json({ messages });
} catch (err) {
} catch (err) {
console.error('[DM] Get messages error:', err.message);
console.error('[DM] Get messages error:', err.message);
@@ -192,10 +192,10 @@ router.get('/conversations/:id/messages', (req, res) => {
@@ -192,10 +192,10 @@ router.get('/conversations/:id/messages', (req, res) => {
});
});
// Send a message
// Send a message
-router.post('/conversations/:id/messages', sendLimit, (req, res) => {
+router.post('/conversations/:id/messages', sendLimit, async (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
+ if (!await dm.isParticipant(convId, req.user.id)) {
return res.status(403).json({ error: 'Not a participant' });
return res.status(403).json({ error: 'Not a participant' });
}
}
@@ -205,11 +205,11 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
@@ -205,11 +205,11 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
}
}
// Block check for 1-on-1 conversations
// Block check for 1-on-1 conversations
- const conv = dm.getConversation(convId);
+ const conv = await dm.getConversation(convId);
if (conv && !conv.is_group) {
if (conv && !conv.is_group) {
- const participants = dm.getParticipants(convId);
+ const participants = await dm.getParticipants(convId);
const other = participants.find(p => p.id !== req.user.id);
const other = participants.find(p => p.id !== req.user.id);
- if (other && dm.isBlockedEither(req.user.id, other.id)) {
+ if (other && await dm.isBlockedEither(req.user.id, other.id)) {
return res.status(403).json({ error: 'Cannot send messages in this conversation' });
return res.status(403).json({ error: 'Cannot send messages in this conversation' });
}
}
}
}
@@ -219,11 +219,11 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
@@ -219,11 +219,11 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
return res.status(400).json({ error: 'Message required' });
return res.status(400).json({ error: 'Message required' });
}
}
- const msg = publicMessage(dm.sendMessage(convId, req.user.id, message));
+ const msg = publicMessage(await dm.sendMessage(convId, req.user.id, message));
if (!msg) return res.status(400).json({ error: 'Failed to send' });
if (!msg) return res.status(400).json({ error: 'Failed to send' });
// Attach sender info
// Attach sender info
- const sender = ctx.getUserById(req.user.id);
+ const sender = await ctx.getUserById(req.user.id);
msg.username = sender?.username;
msg.username = sender?.username;
msg.display_name = sender?.display_name;
msg.display_name = sender?.display_name;
msg.avatar_url = sender?.avatar_url;
msg.avatar_url = sender?.avatar_url;
@@ -232,10 +232,10 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
@@ -232,10 +232,10 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
// Real-time delivery via chat WebSocket
// Real-time delivery via chat WebSocket
try {
try {
const chatServer = require('./chat-server');
const chatServer = require('./chat-server');
- const participants = dm.getParticipants(convId);
+ const participants = await dm.getParticipants(convId);
for (const p of participants) {
for (const p of participants) {
if (p.id === req.user.id) continue; // skip sender
if (p.id === req.user.id) continue; // skip sender
- chatServer.sendDm(p.id, {
+ await chatServer.sendDm(p.id, {
type: 'dm',
type: 'dm',
conversation_id: convId,
conversation_id: convId,
message: msg,
message: msg,
@@ -246,7 +246,7 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
@@ -246,7 +246,7 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
// Push notifications to other participants (for when they're offline) — Live's
// Push notifications to other participants (for when they're offline) — Live's
// utils/notify pushes them to the Network; the preview stays generic there.
// utils/notify pushes them to the Network; the preview stays generic there.
try {
try {
- const participants = dm.getParticipants(convId);
+ const participants = await dm.getParticipants(convId);
const otherIds = participants.filter(p => p.id !== req.user.id).map(p => p.id);
const otherIds = participants.filter(p => p.id !== req.user.id).map(p => p.id);
if (otherIds.length) {
if (otherIds.length) {
ctx.effects.notifyDm({ recipient_ids: otherIds, sender_id: req.user.id, conversation_id: convId });
ctx.effects.notifyDm({ recipient_ids: otherIds, sender_id: req.user.id, conversation_id: convId });
@@ -261,13 +261,13 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
@@ -261,13 +261,13 @@ router.post('/conversations/:id/messages', sendLimit, (req, res) => {
});
});
// Mark conversation read
// Mark conversation read
-router.post('/conversations/:id/read', readLimit, (req, res) => {
+router.post('/conversations/:id/read', readLimit, async (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
+ if (!await dm.isParticipant(convId, req.user.id)) {
return res.status(403).json({ error: 'Not a participant' });
return res.status(403).json({ error: 'Not a participant' });
}
}
- dm.markRead(convId, req.user.id);
+ await dm.markRead(convId, req.user.id);
// Clear matching DM notifications on the Network (through Live's utils/notify)
// Clear matching DM notifications on the Network (through Live's utils/notify)
try {
try {
@@ -285,7 +285,7 @@ router.post('/conversations/:id/read', readLimit, (req, res) => {
@@ -285,7 +285,7 @@ router.post('/conversations/:id/read', readLimit, (req, res) => {
router.post('/conversations/:id/participants', manageLimit, async (req, res) => {
router.post('/conversations/:id/participants', manageLimit, async (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
+ if (!await dm.isParticipant(convId, req.user.id)) {
return res.status(403).json({ error: 'Not a participant' });
return res.status(403).json({ error: 'Not a participant' });
}
}
const { user_id } = req.body;
const { user_id } = req.body;
@@ -293,17 +293,19 @@ router.post('/conversations/:id/participants', manageLimit, async (req, res) =>
@@ -293,17 +293,19 @@ router.post('/conversations/:id/participants', manageLimit, async (req, res) =>
// Validate target user exists, isn't banned, isn't blocked (accounts are Live's)
// Validate target user exists, isn't banned, isn't blocked (accounts are Live's)
try { await ctx.ensureUsers([user_id]); } catch { /* the projection answers below */ }
try { await ctx.ensureUsers([user_id]); } catch { /* the projection answers below */ }
- const target = ctx.getUserById(user_id);
+ const target = await ctx.getUserById(user_id);
if (!target) return res.status(400).json({ error: 'User not found' });
if (!target) return res.status(400).json({ error: 'User not found' });
if (target.is_banned) return res.status(400).json({ error: 'Cannot add banned users' });
if (target.is_banned) return res.status(400).json({ error: 'Cannot add banned users' });
// A private 1:1 stays private: adding a third person would hand them the whole history.
// A private 1:1 stays private: adding a third person would hand them the whole history.
- const conv = dm.getConversation(convId);
+ const conv = await dm.getConversation(convId);
if (!conv || !conv.is_group) {
if (!conv || !conv.is_group) {
return res.status(400).json({ error: 'Start a group conversation to add people' });
return res.status(400).json({ error: 'Start a group conversation to add people' });
}
}
// Nobody already in the group should end up talking to someone they blocked (or who blocked them).
// Nobody already in the group should end up talking to someone they blocked (or who blocked them).
- const members = dm.getParticipants(convId) || [];
- if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {
+ const members = await dm.getParticipants(convId) || [];
+ let blocked = false;
+ for (const p of members) if (await dm.isBlockedEither(p.user_id || p.id, user_id)) { blocked = true; break; }
+ if (blocked) {
return res.status(403).json({ error: 'Cannot add this user' });
return res.status(403).json({ error: 'Cannot add this user' });
}
}
if ((await dmSettingsOf(target)).group_invites === false) {
if ((await dmSettingsOf(target)).group_invites === false) {
@@ -311,18 +313,18 @@ router.post('/conversations/:id/participants', manageLimit, async (req, res) =>
@@ -311,18 +313,18 @@ router.post('/conversations/:id/participants', manageLimit, async (req, res) =>
}
}
// Group size limit
// Group size limit
- if (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {
+ if (await dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {
return res.status(400).json({ error: `Group is full (max ${MAX_GROUP_SIZE} members)` });
return res.status(400).json({ error: `Group is full (max ${MAX_GROUP_SIZE} members)` });
}
}
- dm.addParticipant(convId, user_id);
- const participants = dm.getParticipants(convId);
+ await dm.addParticipant(convId, user_id);
+ const participants = await dm.getParticipants(convId);
// Notify all participants about the new member
// Notify all participants about the new member
try {
try {
const chatServer = require('./chat-server');
const chatServer = require('./chat-server');
for (const p of participants) {
for (const p of participants) {
- chatServer.sendDm(p.id, {
+ await chatServer.sendDm(p.id, {
type: 'dm-participant-added',
type: 'dm-participant-added',
conversation_id: convId,
conversation_id: convId,
user_id,
user_id,
@@ -339,19 +341,19 @@ router.post('/conversations/:id/participants', manageLimit, async (req, res) =>
@@ -339,19 +341,19 @@ router.post('/conversations/:id/participants', manageLimit, async (req, res) =>
});
});
// Remove participant from group
// Remove participant from group
-router.delete('/conversations/:id/participants/:userId', manageLimit, (req, res) => {
+router.delete('/conversations/:id/participants/:userId', manageLimit, async (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
const targetUserId = parseInt(req.params.userId);
const targetUserId = parseInt(req.params.userId);
- if (!dm.isParticipant(convId, req.user.id)) {
+ if (!await dm.isParticipant(convId, req.user.id)) {
return res.status(403).json({ error: 'Not a participant' });
return res.status(403).json({ error: 'Not a participant' });
}
}
// Can only remove self (leave) or be the creator
// Can only remove self (leave) or be the creator
- const conv = dm.getConversation(convId);
+ const conv = await dm.getConversation(convId);
if (targetUserId !== req.user.id && conv?.created_by !== req.user.id) {
if (targetUserId !== req.user.id && conv?.created_by !== req.user.id) {
return res.status(403).json({ error: 'Only the creator can remove others' });
return res.status(403).json({ error: 'Only the creator can remove others' });
}
}
- dm.removeParticipant(convId, targetUserId);
+ await dm.removeParticipant(convId, targetUserId);
res.json({ ok: true });
res.json({ ok: true });
} catch (err) {
} catch (err) {
console.error('[DM] Remove participant error:', err.message);
console.error('[DM] Remove participant error:', err.message);
@@ -360,17 +362,17 @@ router.delete('/conversations/:id/participants/:userId', manageLimit, (req, res)
@@ -360,17 +362,17 @@ router.delete('/conversations/:id/participants/:userId', manageLimit, (req, res)
});
});
// Rename group conversation
// Rename group conversation
-router.patch('/conversations/:id', manageLimit, (req, res) => {
+router.patch('/conversations/:id', manageLimit, async (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
+ if (!await dm.isParticipant(convId, req.user.id)) {
return res.status(403).json({ error: 'Not a participant' });
return res.status(403).json({ error: 'Not a participant' });
}
}
let { name } = req.body;
let { name } = req.body;
if (name) {
if (name) {
name = String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100);
name = String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100);
}
}
- dm.renameConversation(convId, name || null);
+ await dm.renameConversation(convId, name || null);
res.json({ ok: true });
res.json({ ok: true });
} catch (err) {
} catch (err) {
console.error('[DM] Rename error:', err.message);
console.error('[DM] Rename error:', err.message);
@@ -379,9 +381,9 @@ router.patch('/conversations/:id', manageLimit, (req, res) => {
@@ -379,9 +381,9 @@ router.patch('/conversations/:id', manageLimit, (req, res) => {
});
});
// Total unread count
// Total unread count
-router.get('/unread', (req, res) => {
+router.get('/unread', async (req, res) => {
try {
try {
- const total = dm.getTotalUnread(req.user.id);
+ const total = await dm.getTotalUnread(req.user.id);
res.json({ unread: total });
res.json({ unread: total });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed' });
res.status(500).json({ error: 'Failed' });
@@ -389,11 +391,11 @@ router.get('/unread', (req, res) => {
@@ -389,11 +391,11 @@ router.get('/unread', (req, res) => {
});
});
// Search users for new-message picker
// Search users for new-message picker
-router.get('/users/search', (req, res) => {
+router.get('/users/search', async (req, res) => {
try {
try {
const q = (req.query.q || '').trim();
const q = (req.query.q || '').trim();
if (q.length < 2) return res.json({ users: [] });
if (q.length < 2) return res.json({ users: [] });
- const users = dm.searchUsers(q, req.user.id);
+ const users = await dm.searchUsers(q, req.user.id);
res.json({ users });
res.json({ users });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed' });
res.status(500).json({ error: 'Failed' });
@@ -403,13 +405,13 @@ router.get('/users/search', (req, res) => {
@@ -403,13 +405,13 @@ router.get('/users/search', (req, res) => {
// ── Block & message management ───────────────────────────────
// ── Block & message management ───────────────────────────────
// Block a user
// Block a user
-router.post('/blocks/:userId', manageLimit, (req, res) => {
+router.post('/blocks/:userId', manageLimit, async (req, res) => {
try {
try {
const targetId = parseInt(req.params.userId);
const targetId = parseInt(req.params.userId);
if (!targetId || targetId === req.user.id) {
if (!targetId || targetId === req.user.id) {
return res.status(400).json({ error: 'Invalid user' });
return res.status(400).json({ error: 'Invalid user' });
}
}
- dm.blockUser(req.user.id, targetId);
+ await dm.blockUser(req.user.id, targetId);
res.json({ ok: true });
res.json({ ok: true });
} catch (err) {
} catch (err) {
console.error('[DM] Block error:', err.message);
console.error('[DM] Block error:', err.message);
@@ -418,10 +420,10 @@ router.post('/blocks/:userId', manageLimit, (req, res) => {
@@ -418,10 +420,10 @@ router.post('/blocks/:userId', manageLimit, (req, res) => {
});
});
// Unblock a user
// Unblock a user
-router.delete('/blocks/:userId', manageLimit, (req, res) => {
+router.delete('/blocks/:userId', manageLimit, async (req, res) => {
try {
try {
const targetId = parseInt(req.params.userId);
const targetId = parseInt(req.params.userId);
- dm.unblockUser(req.user.id, targetId);
+ await dm.unblockUser(req.user.id, targetId);
res.json({ ok: true });
res.json({ ok: true });
} catch (err) {
} catch (err) {
console.error('[DM] Unblock error:', err.message);
console.error('[DM] Unblock error:', err.message);
@@ -430,9 +432,9 @@ router.delete('/blocks/:userId', manageLimit, (req, res) => {
@@ -430,9 +432,9 @@ router.delete('/blocks/:userId', manageLimit, (req, res) => {
});
});
// List blocked users
// List blocked users
-router.get('/blocks', (req, res) => {
+router.get('/blocks', async (req, res) => {
try {
try {
- const blocked = dm.getBlockedUsers(req.user.id);
+ const blocked = await dm.getBlockedUsers(req.user.id);
res.json({ blocked });
res.json({ blocked });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to load blocked users' });
res.status(500).json({ error: 'Failed to load blocked users' });
@@ -440,24 +442,24 @@ router.get('/blocks', (req, res) => {
@@ -440,24 +442,24 @@ router.get('/blocks', (req, res) => {
});
});
// Check if a specific user is blocked
// Check if a specific user is blocked
-router.get('/blocks/check/:userId', (req, res) => {
+router.get('/blocks/check/:userId', async (req, res) => {
try {
try {
const targetId = parseInt(req.params.userId);
const targetId = parseInt(req.params.userId);
- res.json({ blocked: dm.hasBlocked(req.user.id, targetId) });
+ res.json({ blocked: await dm.hasBlocked(req.user.id, targetId) });
} catch {
} catch {
res.json({ blocked: false });
res.json({ blocked: false });
}
}
});
});
// Delete own message
// Delete own message
-router.delete('/conversations/:id/messages/:msgId', deleteLimit, (req, res) => {
+router.delete('/conversations/:id/messages/:msgId', deleteLimit, async (req, res) => {
try {
try {
const convId = parseInt(req.params.id);
const convId = parseInt(req.params.id);
const msgId = parseInt(req.params.msgId);
const msgId = parseInt(req.params.msgId);
- if (!dm.isParticipant(convId, req.user.id)) {
+ if (!await dm.isParticipant(convId, req.user.id)) {
return res.status(403).json({ error: 'Not a participant' });
return res.status(403).json({ error: 'Not a participant' });
}
}
- const deleted = dm.deleteMessage(msgId, req.user.id);
+ const deleted = await dm.deleteMessage(msgId, req.user.id);
if (!deleted) return res.status(403).json({ error: 'Cannot delete this message' });
if (!deleted) return res.status(403).json({ error: 'Cannot delete this message' });
res.json({ ok: true });
res.json({ ok: true });
} catch (err) {
} catch (err) {
@@ -23,7 +23,7 @@ const networkBlocks = require('./network-blocks');
@@ -23,7 +23,7 @@ const networkBlocks = require('./network-blocks');
// ── Schema & Migrations ──────────────────────────────────────
// ── Schema & Migrations ──────────────────────────────────────
function ensureTables() {
function ensureTables() {
- // The DM tables are created with the rest of Chat's schema (server/db/schema.sql).
+ // The DM tables are created with the rest of Chat's schema (migrations/0001_initial.sql).
db.getDb();
db.getDb();
}
}
@@ -33,8 +33,8 @@ function ensureTables() {
@@ -33,8 +33,8 @@ function ensureTables() {
* Find an existing 1-on-1 conversation between two users.
* Find an existing 1-on-1 conversation between two users.
* Returns conversation row or null.
* Returns conversation row or null.
*/
*/
-function findDirectConversation(userIdA, userIdB) {
- return db.get(`
+async function findDirectConversation(userIdA, userIdB) {
+ return await db.get(`
SELECT c.* FROM dm_conversations c
SELECT c.* FROM dm_conversations c
JOIN dm_participants p1 ON p1.conversation_id = c.id AND p1.user_id = ?
JOIN dm_participants p1 ON p1.conversation_id = c.id AND p1.user_id = ?
JOIN dm_participants p2 ON p2.conversation_id = c.id AND p2.user_id = ?
JOIN dm_participants p2 ON p2.conversation_id = c.id AND p2.user_id = ?
@@ -46,18 +46,18 @@ function findDirectConversation(userIdA, userIdB) {
@@ -46,18 +46,18 @@ function findDirectConversation(userIdA, userIdB) {
* Create a new conversation. Returns the new conversation id.
* Create a new conversation. Returns the new conversation id.
* participantIds should include the creator.
* participantIds should include the creator.
*/
*/
-function createConversation(createdBy, participantIds, name = null) {
+async function createConversation(createdBy, participantIds, name = null) {
const isGroup = participantIds.length > 2 ? 1 : 0;
const isGroup = participantIds.length > 2 ? 1 : 0;
- const result = db.run(
+ const result = await db.run(
`INSERT INTO dm_conversations (name, is_group, created_by) VALUES (?, ?, ?)`,
`INSERT INTO dm_conversations (name, is_group, created_by) VALUES (?, ?, ?)`,
[isGroup ? (name || null) : null, isGroup, createdBy]
[isGroup ? (name || null) : null, isGroup, createdBy]
);
);
const convId = result.lastInsertRowid;
const convId = result.lastInsertRowid;
const insert = db.getDb().prepare(
const insert = db.getDb().prepare(
- `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?)`
+ `INSERT INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`
);
);
for (const uid of participantIds) {
for (const uid of participantIds) {
- insert.run(convId, uid, db.subjectFor(uid));
+ await insert.run(convId, uid, await db.subjectFor(uid));
}
}
return convId;
return convId;
}
}
@@ -65,35 +65,35 @@ function createConversation(createdBy, participantIds, name = null) {
@@ -65,35 +65,35 @@ function createConversation(createdBy, participantIds, name = null) {
/**
/**
* Get or create a 1-on-1 conversation between two users.
* Get or create a 1-on-1 conversation between two users.
*/
*/
-function getOrCreateDirect(userIdA, userIdB) {
- const existing = findDirectConversation(userIdA, userIdB);
+async function getOrCreateDirect(userIdA, userIdB) {
+ const existing = await findDirectConversation(userIdA, userIdB);
if (existing) return existing.id;
if (existing) return existing.id;
- return createConversation(userIdA, [userIdA, userIdB]);
+ return await createConversation(userIdA, [userIdA, userIdB]);
}
}
/**
/**
* Add a participant to an existing group conversation.
* Add a participant to an existing group conversation.
*/
*/
-function addParticipant(conversationId, userId) {
- db.run(
- `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?)`,
- [conversationId, userId, db.subjectFor(userId)]
+async function addParticipant(conversationId, userId) {
+ await db.run(
+ `INSERT INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`,
+ [conversationId, userId, await db.subjectFor(userId)]
);
);
// Mark as group if > 2 participants
// Mark as group if > 2 participants
- const count = db.get(
+ const count = (await db.get(
`SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,
`SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,
[conversationId]
[conversationId]
- )?.c || 0;
+ ))?.c || 0;
if (count > 2) {
if (count > 2) {
- db.run(`UPDATE dm_conversations SET is_group = 1 WHERE id = ?`, [conversationId]);
+ await db.run(`UPDATE dm_conversations SET is_group = 1 WHERE id = ?`, [conversationId]);
}
}
}
}
/**
/**
* Remove a participant from a group conversation.
* Remove a participant from a group conversation.
*/
*/
-function removeParticipant(conversationId, userId) {
- db.run(
+async function removeParticipant(conversationId, userId) {
+ await db.run(
`DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,
`DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,
[conversationId, userId]
[conversationId, userId]
);
);
@@ -102,8 +102,8 @@ function removeParticipant(conversationId, userId) {
@@ -102,8 +102,8 @@ function removeParticipant(conversationId, userId) {
/**
/**
* Check if a user is a participant in a conversation.
* Check if a user is a participant in a conversation.
*/
*/
-function isParticipant(conversationId, userId) {
- return !!db.get(
+async function isParticipant(conversationId, userId) {
+ return !!await db.get(
`SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,
`SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,
[conversationId, userId]
[conversationId, userId]
);
);
@@ -112,15 +112,15 @@ function isParticipant(conversationId, userId) {
@@ -112,15 +112,15 @@ function isParticipant(conversationId, userId) {
/**
/**
* Rename a group conversation.
* Rename a group conversation.
*/
*/
-function renameConversation(conversationId, name) {
- db.run(`UPDATE dm_conversations SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [name, conversationId]);
+async function renameConversation(conversationId, name) {
+ await db.run(`UPDATE dm_conversations SET name = ?, updated_at = ov_now() WHERE id = ?`, [name, conversationId]);
}
}
/**
/**
* Get all conversations for a user with last message preview and unread count.
* Get all conversations for a user with last message preview and unread count.
*/
*/
-function getConversations(userId) {
- return db.all(`
+async function getConversations(userId) {
+ return await db.all(`
SELECT
SELECT
c.id,
c.id,
c.name,
c.name,
@@ -140,8 +140,8 @@ function getConversations(userId) {
@@ -140,8 +140,8 @@ function getConversations(userId) {
/**
/**
* Get participants of a conversation (with user profile info).
* Get participants of a conversation (with user profile info).
*/
*/
-function getParticipants(conversationId) {
- return db.all(`
+async function getParticipants(conversationId) {
+ return await db.all(`
SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color
SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color
FROM dm_participants p
FROM dm_participants p
JOIN ctx_users u ON u.id = p.user_id
JOIN ctx_users u ON u.id = p.user_id
@@ -152,8 +152,8 @@ function getParticipants(conversationId) {
@@ -152,8 +152,8 @@ function getParticipants(conversationId) {
/**
/**
* Get a single conversation by id (with participant info for the requesting user).
* Get a single conversation by id (with participant info for the requesting user).
*/
*/
-function getConversation(conversationId) {
- return db.get(`SELECT * FROM dm_conversations WHERE id = ?`, [conversationId]) || null;
+async function getConversation(conversationId) {
+ return await db.get(`SELECT * FROM dm_conversations WHERE id = ?`, [conversationId]) || null;
}
}
// ── Message helpers ──────────────────────────────────────────
// ── Message helpers ──────────────────────────────────────────
@@ -161,22 +161,22 @@ function getConversation(conversationId) {
@@ -161,22 +161,22 @@ function getConversation(conversationId) {
/**
/**
* Send a message in a conversation. Returns the message row.
* Send a message in a conversation. Returns the message row.
*/
*/
-function sendMessage(conversationId, senderId, text) {
+async function sendMessage(conversationId, senderId, text) {
if (!text || !text.trim()) return null;
if (!text || !text.trim()) return null;
const trimmed = text.trim().slice(0, 2000); // max 2000 chars
const trimmed = text.trim().slice(0, 2000); // max 2000 chars
- const senderSubject = db.subjectFor(senderId);
- return db.transaction(() => {
- const result = db.run(
+ const senderSubject = await db.subjectFor(senderId);
+ return await db.tx(async () => {
+ const result = await db.run(
`INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id) VALUES (?, ?, ?, ?)`,
`INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id) VALUES (?, ?, ?, ?)`,
[conversationId, senderId, trimmed, senderSubject]
[conversationId, senderId, trimmed, senderSubject]
);
);
// Touch conversation updated_at
// Touch conversation updated_at
- db.run(`UPDATE dm_conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [conversationId]);
+ await db.run(`UPDATE dm_conversations SET updated_at = ov_now() WHERE id = ?`, [conversationId]);
// Auto-mark read for sender
// Auto-mark read for sender
- markRead(conversationId, senderId);
+ await markRead(conversationId, senderId);
// The event names who is in the conversation, never what was said.
// The event names who is in the conversation, never what was said.
- const participants = db.all('SELECT user_id, subject_id FROM dm_participants WHERE conversation_id = ?', [conversationId]);
- outbox.enqueue({
+ const participants = await db.all('SELECT user_id, subject_id FROM dm_participants WHERE conversation_id = ?', [conversationId]);
+ await outbox.enqueue({
event_type: 'chat.dm.created',
event_type: 'chat.dm.created',
visibility: 'subject',
visibility: 'subject',
actorSubject: senderSubject,
actorSubject: senderSubject,
@@ -186,20 +186,20 @@ function sendMessage(conversationId, senderId, text) {
@@ -186,20 +186,20 @@ function sendMessage(conversationId, senderId, text) {
conversation_id: Number(conversationId),
conversation_id: Number(conversationId),
sender_user_id: senderId,
sender_user_id: senderId,
sender_subject: senderSubject,
sender_subject: senderSubject,
- participants: participants.map((p) => ({ user_id: p.user_id, subject: p.subject_id || db.subjectFor(p.user_id) })),
+ participants: (await Promise.all(participants.map(async (p) => ({ user_id: p.user_id, subject: p.subject_id || await db.subjectFor(p.user_id) })))),
},
},
});
});
- return db.get(`SELECT * FROM dm_messages WHERE id = ?`, [result.lastInsertRowid]);
+ return await db.get(`SELECT * FROM dm_messages WHERE id = ?`, [result.lastInsertRowid]);
});
});
}
}
/**
/**
* Get messages in a conversation (paginated, newest first).
* Get messages in a conversation (paginated, newest first).
*/
*/
-function getMessages(conversationId, limit = 50, before = null, after = null) {
+async function getMessages(conversationId, limit = 50, before = null, after = null) {
if (after) {
if (after) {
// Fetch messages newer than `after` id (for live polling)
// Fetch messages newer than `after` id (for live polling)
- return db.all(`
+ return await db.all(`
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
FROM dm_messages m
FROM dm_messages m
JOIN ctx_users u ON u.id = m.sender_id
JOIN ctx_users u ON u.id = m.sender_id
@@ -209,7 +209,7 @@ function getMessages(conversationId, limit = 50, before = null, after = null) {
@@ -209,7 +209,7 @@ function getMessages(conversationId, limit = 50, before = null, after = null) {
`, [conversationId, after, limit]);
`, [conversationId, after, limit]);
}
}
if (before) {
if (before) {
- return db.all(`
+ return await db.all(`
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
FROM dm_messages m
FROM dm_messages m
JOIN ctx_users u ON u.id = m.sender_id
JOIN ctx_users u ON u.id = m.sender_id
@@ -218,7 +218,7 @@ function getMessages(conversationId, limit = 50, before = null, after = null) {
@@ -218,7 +218,7 @@ function getMessages(conversationId, limit = 50, before = null, after = null) {
LIMIT ?
LIMIT ?
`, [conversationId, before, limit]);
`, [conversationId, before, limit]);
}
}
- return db.all(`
+ return await db.all(`
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
FROM dm_messages m
FROM dm_messages m
JOIN ctx_users u ON u.id = m.sender_id
JOIN ctx_users u ON u.id = m.sender_id
@@ -231,9 +231,9 @@ function getMessages(conversationId, limit = 50, before = null, after = null) {
@@ -231,9 +231,9 @@ function getMessages(conversationId, limit = 50, before = null, after = null) {
/**
/**
* Mark a conversation as read for a user (set last_read_at to now).
* Mark a conversation as read for a user (set last_read_at to now).
*/
*/
-function markRead(conversationId, userId) {
- db.run(
- `UPDATE dm_participants SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = ? AND user_id = ?`,
+async function markRead(conversationId, userId) {
+ await db.run(
+ `UPDATE dm_participants SET last_read_at = ov_now() WHERE conversation_id = ? AND user_id = ?`,
[conversationId, userId]
[conversationId, userId]
);
);
}
}
@@ -241,14 +241,14 @@ function markRead(conversationId, userId) {
@@ -241,14 +241,14 @@ function markRead(conversationId, userId) {
/**
/**
* Get total unread message count across all conversations for a user.
* Get total unread message count across all conversations for a user.
*/
*/
-function getTotalUnread(userId) {
- const row = db.get(`
- SELECT COALESCE(SUM(unread), 0) as total FROM (
+async function getTotalUnread(userId) {
+ const row = await db.get(`
+ SELECT CAST(COALESCE(SUM(unread), 0) AS BIGINT) as total FROM (
SELECT COUNT(*) as unread
SELECT COUNT(*) as unread
FROM dm_messages m
FROM dm_messages m
JOIN dm_participants p ON p.conversation_id = m.conversation_id AND p.user_id = ?
JOIN dm_participants p ON p.conversation_id = m.conversation_id AND p.user_id = ?
WHERE m.created_at > p.last_read_at AND m.sender_id != ?
WHERE m.created_at > p.last_read_at AND m.sender_id != ?
- )
+ ) AS per_conversation
`, [userId, userId]);
`, [userId, userId]);
return row?.total || 0;
return row?.total || 0;
}
}
@@ -257,11 +257,11 @@ function getTotalUnread(userId) {
@@ -257,11 +257,11 @@ function getTotalUnread(userId) {
* Search users by username/display_name for the "new message" user picker.
* Search users by username/display_name for the "new message" user picker.
* Excludes the requesting user.
* Excludes the requesting user.
*/
*/
-function searchUsers(query, excludeUserId, limit = 10) {
+async function searchUsers(query, excludeUserId, limit = 10) {
if (!query || query.length < 2) return [];
if (!query || query.length < 2) return [];
networkBlocks.ensureSchema();
networkBlocks.ensureSchema();
- const me = db.subjectFor(excludeUserId) || '';
- return db.all(`
+ const me = await db.subjectFor(excludeUserId) || '';
+ return await db.all(`
SELECT id, username, display_name, avatar_url, profile_color
SELECT id, username, display_name, avatar_url, profile_color
FROM ctx_users
FROM ctx_users
WHERE id != ? AND is_banned = 0
WHERE id != ? AND is_banned = 0
@@ -269,7 +269,7 @@ function searchUsers(query, excludeUserId, limit = 10) {
@@ -269,7 +269,7 @@ function searchUsers(query, excludeUserId, limit = 10) {
AND id NOT IN (SELECT blocker_id FROM dm_blocks WHERE blocked_id = ?)
AND id NOT IN (SELECT blocker_id FROM dm_blocks WHERE blocked_id = ?)
AND COALESCE(subject_id, '') NOT IN (SELECT blocked_subject FROM network_blocks WHERE blocker_subject = ? AND active = 1)
AND COALESCE(subject_id, '') NOT IN (SELECT blocked_subject FROM network_blocks WHERE blocker_subject = ? AND active = 1)
AND COALESCE(subject_id, '') NOT IN (SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1)
AND COALESCE(subject_id, '') NOT IN (SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1)
- AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE)
+ AND (username ILIKE ? OR display_name ILIKE ?)
LIMIT ?
LIMIT ?
`, [excludeUserId, excludeUserId, excludeUserId, me, me, `%${query}%`, `%${query}%`, limit]);
`, [excludeUserId, excludeUserId, excludeUserId, me, me, `%${query}%`, `%${query}%`, limit]);
}
}
@@ -280,19 +280,19 @@ function searchUsers(query, excludeUserId, limit = 10) {
@@ -280,19 +280,19 @@ function searchUsers(query, excludeUserId, limit = 10) {
* Check if either user has blocked the other (bidirectional): in Chat's dm_blocks, or on the network
* Check if either user has blocked the other (bidirectional): in Chat's dm_blocks, or on the network
* (platform blocks, ./network-blocks.js, by Network subject).
* (platform blocks, ./network-blocks.js, by Network subject).
*/
*/
-function isBlockedEither(userIdA, userIdB) {
- return !!db.get(
+async function isBlockedEither(userIdA, userIdB) {
+ return !!await db.get(
`SELECT 1 FROM dm_blocks
`SELECT 1 FROM dm_blocks
WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,
WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,
[userIdA, userIdB, userIdB, userIdA]
[userIdA, userIdB, userIdB, userIdA]
- ) || networkBlocks.eitherBlockedUsers(userIdA, userIdB);
+ ) || await networkBlocks.eitherBlockedUsers(userIdA, userIdB);
}
}
/**
/**
* Check if blocker has blocked blocked (one-directional).
* Check if blocker has blocked blocked (one-directional).
*/
*/
-function hasBlocked(blockerId, blockedId) {
- return !!db.get(
+async function hasBlocked(blockerId, blockedId) {
+ return !!await db.get(
`SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,
`SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,
[blockerId, blockedId]
[blockerId, blockedId]
);
);
@@ -301,18 +301,18 @@ function hasBlocked(blockerId, blockedId) {
@@ -301,18 +301,18 @@ function hasBlocked(blockerId, blockedId) {
/**
/**
* Block a user.
* Block a user.
*/
*/
-function blockUser(blockerId, blockedId) {
- db.run(
- `INSERT OR IGNORE INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?)`,
- [blockerId, blockedId, db.subjectFor(blockerId)]
+async function blockUser(blockerId, blockedId) {
+ await db.run(
+ `INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`,
+ [blockerId, blockedId, await db.subjectFor(blockerId)]
);
);
}
}
/**
/**
* Unblock a user.
* Unblock a user.
*/
*/
-function unblockUser(blockerId, blockedId) {
- db.run(
+async function unblockUser(blockerId, blockedId) {
+ await db.run(
`DELETE FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,
`DELETE FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,
[blockerId, blockedId]
[blockerId, blockedId]
);
);
@@ -321,8 +321,8 @@ function unblockUser(blockerId, blockedId) {
@@ -321,8 +321,8 @@ function unblockUser(blockerId, blockedId) {
/**
/**
* Get all users blocked by blockerId.
* Get all users blocked by blockerId.
*/
*/
-function getBlockedUsers(blockerId) {
- return db.all(`
+async function getBlockedUsers(blockerId) {
+ return await db.all(`
SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, b.created_at as blocked_at
SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, b.created_at as blocked_at
FROM dm_blocks b
FROM dm_blocks b
JOIN ctx_users u ON u.id = b.blocked_id
JOIN ctx_users u ON u.id = b.blocked_id
@@ -334,18 +334,18 @@ function getBlockedUsers(blockerId) {
@@ -334,18 +334,18 @@ function getBlockedUsers(blockerId) {
/**
/**
* Delete a message (only the sender can delete their own message).
* Delete a message (only the sender can delete their own message).
*/
*/
-function deleteMessage(messageId, userId) {
- const msg = db.get(`SELECT * FROM dm_messages WHERE id = ?`, [messageId]);
+async function deleteMessage(messageId, userId) {
+ const msg = await db.get(`SELECT * FROM dm_messages WHERE id = ?`, [messageId]);
if (!msg || msg.sender_id !== userId) return false;
if (!msg || msg.sender_id !== userId) return false;
- db.run(`DELETE FROM dm_messages WHERE id = ?`, [messageId]);
+ await db.run(`DELETE FROM dm_messages WHERE id = ?`, [messageId]);
return true;
return true;
}
}
/**
/**
* Get the number of participants in a conversation.
* Get the number of participants in a conversation.
*/
*/
-function getParticipantCount(conversationId) {
- const row = db.get(
+async function getParticipantCount(conversationId) {
+ const row = await db.get(
`SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,
`SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,
[conversationId]
[conversationId]
);
);
@@ -399,8 +399,8 @@ function checkMessageSpam(text) {
@@ -399,8 +399,8 @@ function checkMessageSpam(text) {
* Check if this message is a duplicate of the user's recent messages.
* Check if this message is a duplicate of the user's recent messages.
* Returns true if the exact same text was sent within the last N seconds.
* Returns true if the exact same text was sent within the last N seconds.
*/
*/
-function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30) {
- const row = db.get(`
+async function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30) {
+ const row = await db.get(`
SELECT 1 FROM dm_messages
SELECT 1 FROM dm_messages
WHERE conversation_id = ? AND sender_id = ? AND message = ?
WHERE conversation_id = ? AND sender_id = ? AND message = ?
AND created_at > datetime('now', '-' || ? || ' seconds')
AND created_at > datetime('now', '-' || ? || ' seconds')
@@ -413,8 +413,8 @@ function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30)
@@ -413,8 +413,8 @@ function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30)
* Check if a user account is too new to send DMs (minimum account age).
* Check if a user account is too new to send DMs (minimum account age).
* Returns { tooNew: boolean, minutesRemaining: number }
* Returns { tooNew: boolean, minutesRemaining: number }
*/
*/
-function isAccountTooNew(userId, minMinutes = 5) {
- const user = db.get(`SELECT created_at FROM ctx_users WHERE id = ?`, [userId]);
+async function isAccountTooNew(userId, minMinutes = 5) {
+ const user = await db.get(`SELECT created_at FROM ctx_users WHERE id = ?`, [userId]);
if (!user || !user.created_at) return { tooNew: false, minutesRemaining: 0 };
if (!user || !user.created_at) return { tooNew: false, minutesRemaining: 0 };
const created = new Date(user.created_at.endsWith('Z') ? user.created_at : user.created_at + 'Z');
const created = new Date(user.created_at.endsWith('Z') ? user.created_at : user.created_at + 'Z');
const ageMs = Date.now() - created.getTime();
const ageMs = Date.now() - created.getTime();
@@ -190,9 +190,9 @@ const DEFAULT_EMOTES = [
@@ -190,9 +190,9 @@ const DEFAULT_EMOTES = [
// ROUTES
// ROUTES
// ══════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════
-router.get('/global', (req, res) => {
+router.get('/global', async (req, res) => {
try {
try {
- const emotes = db.getGlobalEmotes().map((e) => ({
+ const emotes = (await db.getGlobalEmotes()).map((e) => ({
id: `custom-${e.id}`,
id: `custom-${e.id}`,
code: e.code,
code: e.code,
url: fileUrl(e),
url: fileUrl(e),
@@ -208,10 +208,10 @@ router.get('/global', (req, res) => {
@@ -208,10 +208,10 @@ router.get('/global', (req, res) => {
}
}
});
});
-router.get('/channel/:userId', (req, res) => {
+router.get('/channel/:userId', async (req, res) => {
try {
try {
const userId = parseInt(req.params.userId);
const userId = parseInt(req.params.userId);
- const emotes = db.getChannelEmotes(userId).map((e) => ({
+ const emotes = (await db.getChannelEmotes(userId)).map((e) => ({
id: `custom-${e.id}`,
id: `custom-${e.id}`,
emote_id: e.id,
emote_id: e.id,
code: e.code,
code: e.code,
@@ -230,9 +230,9 @@ router.get('/channel/:userId', (req, res) => {
@@ -230,9 +230,9 @@ router.get('/channel/:userId', (req, res) => {
}
}
});
});
-router.get('/mine', requireAuth, (req, res) => {
+router.get('/mine', requireAuth, async (req, res) => {
try {
try {
- const emotes = db.getEmotesByUser(req.user.id).map((e) => ({
+ const emotes = (await db.getEmotesByUser(req.user.id)).map((e) => ({
id: e.id,
id: e.id,
code: e.code,
code: e.code,
url: fileUrl(e),
url: fileUrl(e),
@@ -242,7 +242,7 @@ router.get('/mine', requireAuth, (req, res) => {
@@ -242,7 +242,7 @@ router.get('/mine', requireAuth, (req, res) => {
is_global: !!e.is_global,
is_global: !!e.is_global,
created_at: e.created_at,
created_at: e.created_at,
}));
}));
- res.json({ emotes, count: db.countChannelEmotes(req.user.id), max: config.emotes.maxPerChannel });
+ res.json({ emotes, count: await db.countChannelEmotes(req.user.id), max: config.emotes.maxPerChannel });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to load emotes' });
res.status(500).json({ error: 'Failed to load emotes' });
}
}
@@ -275,25 +275,25 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
@@ -275,25 +275,25 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
const channel = await ctx.ensureChannelForUser(channelOwnerId);
const channel = await ctx.ensureChannelForUser(channelOwnerId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
await ctx.ensurePolicy(channel.id);
await ctx.ensurePolicy(channel.id);
- const settings = ctx.getChannelModerationSettings(channel.id);
- const isMod = permissions.canModerateChannel(req.user, channel.id);
+ const settings = await ctx.getChannelModerationSettings(channel.id);
+ const isMod = await permissions.canModerateChannel(req.user, channel.id);
if (!settings.custom_emotes_enabled && !isMod) {
if (!settings.custom_emotes_enabled && !isMod) {
return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });
return res.status(403).json({ error: 'This streamer has disabled viewer emote uploads.' });
}
}
if (settings.uploads_mods_only && !isMod) {
if (settings.uploads_mods_only && !isMod) {
return res.status(403).json({ error: 'Only channel mods can upload emotes here.' });
return res.status(403).json({ error: 'Only channel mods can upload emotes here.' });
}
}
- if (db.countChannelEmotes(channelOwnerId) >= config.emotes.maxPerChannel) {
+ if (await db.countChannelEmotes(channelOwnerId) >= config.emotes.maxPerChannel) {
return res.status(400).json({ error: `This channel is full (${config.emotes.maxPerChannel} emotes max) — the streamer needs to remove some first.` });
return res.status(400).json({ error: `This channel is full (${config.emotes.maxPerChannel} emotes max) — the streamer needs to remove some first.` });
}
}
- if (db.getChannelEmoteByCode(channelOwnerId, code)) {
+ if (await db.getChannelEmoteByCode(channelOwnerId, code)) {
return res.status(409).json({ error: `This channel already has an emote named "${code}".` });
return res.status(409).json({ error: `This channel already has an emote named "${code}".` });
}
}
} else {
} else {
- if (db.countChannelEmotes(req.user.id) >= config.emotes.maxPerChannel) {
+ if (await db.countChannelEmotes(req.user.id) >= config.emotes.maxPerChannel) {
return res.status(400).json({ error: `Your channel is full (${config.emotes.maxPerChannel} emotes max) — remove some to add more.` });
return res.status(400).json({ error: `Your channel is full (${config.emotes.maxPerChannel} emotes max) — remove some to add more.` });
}
}
- const existing = db.get('SELECT id FROM emotes WHERE user_id = ? AND code = ? AND channel_owner_id IS NULL', [req.user.id, code]);
+ const existing = await db.get('SELECT id FROM emotes WHERE user_id = ? AND code = ? AND channel_owner_id IS NULL', [req.user.id, code]);
if (existing) return res.status(409).json({ error: `You already have an emote named "${code}"` });
if (existing) return res.status(409).json({ error: `You already have an emote named "${code}"` });
}
}
@@ -305,13 +305,13 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
@@ -305,13 +305,13 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
let sizeMin = 25, sizeMax = 400;
let sizeMin = 25, sizeMax = 400;
if (channelOwnerId) {
if (channelOwnerId) {
const channel = await ctx.ensureChannelForUser(channelOwnerId);
const channel = await ctx.ensureChannelForUser(channelOwnerId);
- const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;
+ const st = channel ? await ctx.getChannelModerationSettings(channel.id) : null;
if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
}
}
const size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
const size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
const uploaded = await media.uploadObject(req.file.buffer, { filename: req.file.originalname, mimeType: req.file.mimetype });
const uploaded = await media.uploadObject(req.file.buffer, { filename: req.file.originalname, mimeType: req.file.mimetype });
- const result = db.createEmote({
+ const result = await db.createEmote({
user_id: req.user.id,
user_id: req.user.id,
code,
code,
url: uploaded.url,
url: uploaded.url,
@@ -326,7 +326,7 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
@@ -326,7 +326,7 @@ router.post('/', requireAuth, emoteUpload.single('image'), async (req, res) => {
});
});
if (isChannelUpload) {
if (isChannelUpload) {
- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'emotes-updated' }); } catch { /* */ }
+ try { await require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'emotes-updated' }); } catch { /* */ }
}
}
res.json({
res.json({
emote: {
emote: {
@@ -361,13 +361,13 @@ router.use((err, req, res, next) => {
@@ -361,13 +361,13 @@ router.use((err, req, res, next) => {
router.patch('/:id', requireAuth, async (req, res) => {
router.patch('/:id', requireAuth, async (req, res) => {
try {
try {
- const emote = db.getEmoteById(req.params.id);
+ const emote = await db.getEmoteById(req.params.id);
if (!emote) return res.status(404).json({ error: 'Emote not found' });
if (!emote) return res.status(404).json({ error: 'Emote not found' });
let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
if (!allowed) {
if (!allowed) {
const ownerId = emote.channel_owner_id || emote.user_id;
const ownerId = emote.channel_owner_id || emote.user_id;
const channel = await ctx.ensureChannelForUser(ownerId);
const channel = await ctx.ensureChannelForUser(ownerId);
- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
+ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
}
}
if (!allowed) return res.status(403).json({ error: 'Not your emote' });
if (!allowed) return res.status(403).json({ error: 'Not your emote' });
@@ -381,7 +381,7 @@ router.patch('/:id', requireAuth, async (req, res) => {
@@ -381,7 +381,7 @@ router.patch('/:id', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
}
}
const scopeOwner = emote.channel_owner_id || emote.user_id;
const scopeOwner = emote.channel_owner_id || emote.user_id;
- const clash = (db.getChannelEmotes(scopeOwner) || [])
+ const clash = (await db.getChannelEmotes(scopeOwner) || [])
.find((e) => e.id !== emote.id && String(e.code).toLowerCase() === code.toLowerCase());
.find((e) => e.id !== emote.id && String(e.code).toLowerCase() === code.toLowerCase());
if (clash) return res.status(409).json({ error: `An emote named "${clash.code}" already exists in this channel.` });
if (clash) return res.status(409).json({ error: `An emote named "${clash.code}" already exists in this channel.` });
patch.code = code;
patch.code = code;
@@ -390,7 +390,7 @@ router.patch('/:id', requireAuth, async (req, res) => {
@@ -390,7 +390,7 @@ router.patch('/:id', requireAuth, async (req, res) => {
let sizeMin = 25, sizeMax = 400;
let sizeMin = 25, sizeMax = 400;
const ownerId = emote.channel_owner_id || emote.user_id;
const ownerId = emote.channel_owner_id || emote.user_id;
const channel = await ctx.ensureChannelForUser(ownerId);
const channel = await ctx.ensureChannelForUser(ownerId);
- const st = channel ? ctx.getChannelModerationSettings(channel.id) : null;
+ const st = channel ? await ctx.getChannelModerationSettings(channel.id) : null;
if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
if (st) { sizeMin = st.emote_size_min || 50; sizeMax = st.emote_size_max || 200; }
patch.size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
patch.size = Math.min(sizeMax, Math.max(sizeMin, parseInt(req.body.size) || 100));
}
}
@@ -398,12 +398,12 @@ router.patch('/:id', requireAuth, async (req, res) => {
@@ -398,12 +398,12 @@ router.patch('/:id', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Nothing to change' });
return res.status(400).json({ error: 'Nothing to change' });
}
}
- db.updateEmote(emote.id, patch);
+ await db.updateEmote(emote.id, patch);
if (patch.code && patch.code !== emote.code) {
if (patch.code && patch.code !== emote.code) {
- try { db.updateChannelSoundEmoteRefs(emote.channel_owner_id || emote.user_id, emote.code, patch.code); } catch { /* */ }
+ try { await db.updateChannelSoundEmoteRefs(emote.channel_owner_id || emote.user_id, emote.code, patch.code); } catch { /* */ }
}
}
if (emote.channel_owner_id) {
if (emote.channel_owner_id) {
- try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
+ try { await require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
}
}
res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });
res.json({ message: 'Emote updated', code: patch.code ?? emote.code, size: patch.size ?? emote.size });
} catch (err) {
} catch (err) {
@@ -413,20 +413,20 @@ router.patch('/:id', requireAuth, async (req, res) => {
@@ -413,20 +413,20 @@ router.patch('/:id', requireAuth, async (req, res) => {
router.delete('/:id', requireAuth, async (req, res) => {
router.delete('/:id', requireAuth, async (req, res) => {
try {
try {
- const emote = db.getEmoteById(req.params.id);
+ const emote = await db.getEmoteById(req.params.id);
if (!emote) return res.status(404).json({ error: 'Emote not found' });
if (!emote) return res.status(404).json({ error: 'Emote not found' });
let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
let allowed = emote.user_id === req.user.id || permissions.can(req.user, 'staff.assets.manage');
if (!allowed) {
if (!allowed) {
const ownerId = emote.channel_owner_id || emote.user_id;
const ownerId = emote.channel_owner_id || emote.user_id;
const channel = await ctx.ensureChannelForUser(ownerId);
const channel = await ctx.ensureChannelForUser(ownerId);
- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
+ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
}
}
if (!allowed) return res.status(403).json({ error: 'Not your emote' });
if (!allowed) return res.status(403).json({ error: 'Not your emote' });
- db.deleteEmote(emote.id);
+ await db.deleteEmote(emote.id);
await media.deleteObject(emote.media_asset_id);
await media.deleteObject(emote.media_asset_id);
if (emote.channel_owner_id) {
if (emote.channel_owner_id) {
- try { require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
+ try { await require('./chat-server').broadcastToOwnerStreams(emote.channel_owner_id, { type: 'emotes-updated' }); } catch { /* */ }
}
}
res.json({ message: 'Emote deleted' });
res.json({ message: 'Emote deleted' });
} catch (err) {
} catch (err) {
@@ -575,7 +575,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {
@@ -575,7 +575,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {
let globalCustom = [];
let globalCustom = [];
let channelEmotes = [];
let channelEmotes = [];
if (sources.custom !== false) {
if (sources.custom !== false) {
- globalCustom = db.getGlobalEmotes().map((e) => ({
+ globalCustom = (await db.getGlobalEmotes()).map((e) => ({
id: `custom-${e.id}`,
id: `custom-${e.id}`,
code: e.code,
code: e.code,
url: fileUrl(e),
url: fileUrl(e),
@@ -588,7 +588,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {
@@ -588,7 +588,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {
}));
}));
if (streamUserId) {
if (streamUserId) {
- channelEmotes = db.getChannelEmotes(streamUserId).filter((e) => !e.is_global).map((e) => ({
+ channelEmotes = (await db.getChannelEmotes(streamUserId)).filter((e) => !e.is_global).map((e) => ({
id: `custom-${e.id}`,
id: `custom-${e.id}`,
emote_id: e.id,
emote_id: e.id,
code: e.code,
code: e.code,
@@ -643,7 +643,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {
@@ -643,7 +643,7 @@ router.get('/all/:streamId', optionalAuth, async (req, res) => {
let emoteScale = 100;
let emoteScale = 100;
if (channel) {
if (channel) {
- const st = ctx.getChannelModerationSettings(channel.id);
+ const st = await ctx.getChannelModerationSettings(channel.id);
if (st && st.emote_scale) emoteScale = Number(st.emote_scale) || 100;
if (st && st.emote_scale) emoteScale = Number(st.emote_scale) || 100;
}
}
@@ -76,8 +76,8 @@ function baseSql(r, { channelUsername } = {}) {
@@ -76,8 +76,8 @@ function baseSql(r, { channelUsername } = {}) {
return { sql: CHANNEL_SELECT, params: [r.userId] };
return { sql: CHANNEL_SELECT, params: [r.userId] };
}
}
-function tipId() {
- try { const r = db.get('SELECT MAX(id) AS id FROM chat_messages'); return (r && r.id) || 0; } catch { return Date.now(); }
+async function tipId() {
+ try { const r = await db.get('SELECT MAX(id) AS id FROM chat_messages'); return (r && r.id) || 0; } catch { return Date.now(); }
}
}
const latestIdOf = (rows) => rows.reduce((m, x) => (x && x.id > m ? x.id : m), 0);
const latestIdOf = (rows) => rows.reduce((m, x) => (x && x.id > m ? x.id : m), 0);
@@ -94,37 +94,37 @@ function memoSet(key, value) {
@@ -94,37 +94,37 @@ function memoSet(key, value) {
if (memo.size > 64) { const oldest = memo.keys().next().value; memo.delete(oldest); }
if (memo.size > 64) { const oldest = memo.keys().next().value; memo.delete(oldest); }
}
}
-function readPage(r, { limit, before, channelUsername }) {
+async function readPage(r, { limit, before, channelUsername }) {
const { sql, params } = baseSql(r, { channelUsername });
const { sql, params } = baseSql(r, { channelUsername });
let q = sql;
let q = sql;
if (before) { q += ' AND cm.timestamp < ?'; params.push(before); }
if (before) { q += ' AND cm.timestamp < ?'; params.push(before); }
// The page order is the primary key alone (plan T3 decision 2): ids are monotonic in insert order,
// The page order is the primary key alone (plan T3 decision 2): ids are monotonic in insert order,
// so this is the same order as before, and idx_chat_page_live serves it without a sort on PostgreSQL.
// so this is the same order as before, and idx_chat_page_live serves it without a sort on PostgreSQL.
q += ' ORDER BY cm.id DESC LIMIT ?'; params.push(limit);
q += ' ORDER BY cm.id DESC LIMIT ?'; params.push(limit);
- return db.all(q, params).reverse();
+ return (await db.all(q, params)).reverse();
}
}
-function readDelta(r, { afterId, limit, channelUsername }) {
+async function readDelta(r, { afterId, limit, channelUsername }) {
const { sql, params } = baseSql(r, { channelUsername });
const { sql, params } = baseSql(r, { channelUsername });
// One more than asked: if it comes back, the gap is bigger than the client can splice in.
// One more than asked: if it comes back, the gap is bigger than the client can splice in.
const q = `${sql} AND cm.id > ? ORDER BY cm.id ASC LIMIT ?`;
const q = `${sql} AND cm.id > ? ORDER BY cm.id ASC LIMIT ?`;
params.push(afterId, limit + 1);
params.push(afterId, limit + 1);
- return db.all(q, params);
+ return await db.all(q, params);
}
}
/**
/**
* Newest rows of a room, oldest→newest.
* Newest rows of a room, oldest→newest.
* @returns {{ messages: object[], latest_id: number }}
* @returns {{ messages: object[], latest_id: number }}
*/
*/
-function page(room, { limit, before, channelUsername, decorate } = {}) {
+async function page(room, { limit, before, channelUsername, decorate } = {}) {
const r = parseRoom(room);
const r = parseRoom(room);
const lim = clampLimit(limit);
const lim = clampLimit(limit);
// The newest row id is part of the memo key, so a memoised page can never miss a message:
// The newest row id is part of the memo key, so a memoised page can never miss a message:
// one primary-key lookup instead of the five-table join for every repeat reader in the window.
// one primary-key lookup instead of the five-table join for every repeat reader in the window.
- const key = !before ? `${room}|${lim}|${channelUsername || ''}|${tipId()}` : null;
+ const key = !before ? `${room}|${lim}|${channelUsername || ''}|${await tipId()}` : null;
let rows = key ? memoGet(key) : null;
let rows = key ? memoGet(key) : null;
if (!rows) {
if (!rows) {
- rows = readPage(r, { limit: lim, before, channelUsername });
+ rows = await readPage(r, { limit: lim, before, channelUsername });
if (key) memoSet(key, rows);
if (key) memoSet(key, rows);
}
}
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;
@@ -136,15 +136,15 @@ function page(room, { limit, before, channelUsername, decorate } = {}) {
@@ -136,15 +136,15 @@ function page(room, { limit, before, channelUsername, decorate } = {}) {
* missing; take a fresh page instead of splicing.
* missing; take a fresh page instead of splicing.
* @returns {{ messages: object[], latest_id: number, complete: boolean }}
* @returns {{ messages: object[], latest_id: number, complete: boolean }}
*/
*/
-function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
+async function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
const r = parseRoom(room);
const r = parseRoom(room);
const lim = clampLimit(limit, 200);
const lim = clampLimit(limit, 200);
const after = Math.max(0, parseInt(afterId, 10) || 0);
const after = Math.max(0, parseInt(afterId, 10) || 0);
- let rows = readDelta(r, { afterId: after, limit: lim, channelUsername });
+ let rows = await readDelta(r, { afterId: after, limit: lim, channelUsername });
const complete = rows.length <= lim;
const complete = rows.length <= lim;
if (!complete) rows = rows.slice(0, lim);
if (!complete) rows = rows.slice(0, lim);
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;
const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;
- return { messages, latest_id: latestIdOf(rows) || after, complete, deleted_ids: deletedIds(room, after) };
+ return { messages, latest_id: latestIdOf(rows) || after, complete, deleted_ids: await deletedIds(room, after) };
}
}
/**
/**
@@ -155,7 +155,7 @@ function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
@@ -155,7 +155,7 @@ function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
* @returns {number[]} oldest→newest
* @returns {number[]} oldest→newest
*/
*/
const DELETED_WINDOW = 2 * MAX_LIMIT;
const DELETED_WINDOW = 2 * MAX_LIMIT;
-function deletedIds(room, afterId) {
+async function deletedIds(room, afterId) {
const after = Math.max(0, parseInt(afterId, 10) || 0);
const after = Math.max(0, parseInt(afterId, 10) || 0);
if (!after) return [];
if (!after) return [];
const m = /^stream:(\d+)$/.exec(String(room || ''));
const m = /^stream:(\d+)$/.exec(String(room || ''));
@@ -164,11 +164,11 @@ function deletedIds(room, afterId) {
@@ -164,11 +164,11 @@ function deletedIds(room, afterId) {
: r.kind === 'channel' ? { where: 'channel_user_id = ?', params: [r.userId] }
: r.kind === 'channel' ? { where: 'channel_user_id = ?', params: [r.userId] }
: { where: 'stream_id = ?', params: [r.streamId] };
: { where: 'stream_id = ?', params: [r.streamId] };
// Visible = what page()/delta() return; everything else in the window is reported.
// Visible = what page()/delta() return; everything else in the window is reported.
- return db.all(`SELECT id FROM (
+ return (await db.all(`SELECT id FROM (
SELECT id, is_deleted, auto_delete_at FROM chat_messages
SELECT id, is_deleted, auto_delete_at FROM chat_messages
- WHERE ${scope.where} AND id <= ? ORDER BY timestamp DESC, id DESC LIMIT ?)
- WHERE NOT (is_deleted = 0 AND (auto_delete_at IS NULL OR COALESCE(datetime(auto_delete_at) > datetime('now'), 0)))
- ORDER BY id`, [...scope.params, after, DELETED_WINDOW]).map((x) => x.id);
+ WHERE ${scope.where} AND id <= ? ORDER BY id DESC LIMIT ?) AS recent
+ WHERE NOT (is_deleted = 0 AND (auto_delete_at IS NULL OR COALESCE(datetime(auto_delete_at) > datetime('now'), false)))
+ ORDER BY id`, [...scope.params, after, DELETED_WINDOW])).map((x) => x.id);
}
}
/** Drop memoised pages for a room (or all rooms). Call from write paths when a tier is added. */
/** Drop memoised pages for a room (or all rooms). Call from write paths when a tier is added. */
@@ -24,12 +24,12 @@ const serviceAuth = require('../net/service-auth');
@@ -24,12 +24,12 @@ const serviceAuth = require('../net/service-auth');
const router = express.Router();
const router = express.Router();
const guard = serviceAuth.guard('chat.moderation.read');
const guard = serviceAuth.guard('chat.moderation.read');
-router.get('/channels/:channelId', guard, (req, res) => {
+router.get('/channels/:channelId', guard, async (req, res) => {
try {
try {
const channelId = parseInt(req.params.channelId, 10);
const channelId = parseInt(req.params.channelId, 10);
if (!channelId) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });
if (!channelId) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });
- const settings = db.getChannelModerationSettingsRow(channelId) || ctx.defaultModerationSettings(channelId);
- const moderator_ids = db.getChannelModeratorIds(channelId);
+ const settings = await db.getChannelModerationSettingsRow(channelId) || ctx.defaultModerationSettings(channelId);
+ const moderator_ids = await db.getChannelModeratorIds(channelId);
res.json({ ok: true, settings, moderator_ids });
res.json({ ok: true, settings, moderator_ids });
} catch (err) {
} catch (err) {
console.error('[Moderation] channel read:', err.message);
console.error('[Moderation] channel read:', err.message);
@@ -37,11 +37,11 @@ router.get('/channels/:channelId', guard, (req, res) => {
@@ -37,11 +37,11 @@ router.get('/channels/:channelId', guard, (req, res) => {
}
}
});
});
-router.get('/users/:userId/channels', guard, (req, res) => {
+router.get('/users/:userId/channels', guard, async (req, res) => {
try {
try {
const userId = parseInt(req.params.userId, 10);
const userId = parseInt(req.params.userId, 10);
if (!userId) return res.status(400).json({ ok: false, error: 'Invalid user ID' });
if (!userId) return res.status(400).json({ ok: false, error: 'Invalid user ID' });
- const channels = db.getChannelsByModerator(userId).map((c) => ({
+ const channels = (await db.getChannelsByModerator(userId)).map((c) => ({
channel_id: Number(c.channel_id),
channel_id: Number(c.channel_id),
title: c.title == null ? null : String(c.title),
title: c.title == null ? null : String(c.title),
owner_user_id: c.owner_user_id == null ? null : Number(c.owner_user_id),
owner_user_id: c.owner_user_id == null ? null : Number(c.owner_user_id),
@@ -54,13 +54,13 @@ router.get('/users/:userId/channels', guard, (req, res) => {
@@ -54,13 +54,13 @@ router.get('/users/:userId/channels', guard, (req, res) => {
}
}
});
});
-router.get('/channels/:channelId/emote-count', guard, (req, res) => {
+router.get('/channels/:channelId/emote-count', guard, async (req, res) => {
try {
try {
const raw = parseInt(req.params.channelId, 10);
const raw = parseInt(req.params.channelId, 10);
if (!raw) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });
if (!raw) return res.status(400).json({ ok: false, error: 'Invalid channel ID' });
// A channels.id resolves to its owner's user id (the emotes' key); a user id is used as it is.
// A channels.id resolves to its owner's user id (the emotes' key); a user id is used as it is.
- const ownerId = (ctx.getChannelById(raw) || {}).user_id || raw;
- res.json({ ok: true, count: db.countChannelEmotes(ownerId) });
+ const ownerId = (await ctx.getChannelById(raw) || {}).user_id || raw;
+ res.json({ ok: true, count: await db.countChannelEmotes(ownerId) });
} catch (err) {
} catch (err) {
console.error('[Moderation] emote count:', err.message);
console.error('[Moderation] emote count:', err.message);
res.status(500).json({ ok: false, error: 'Failed to count emotes' });
res.status(500).json({ ok: false, error: 'Failed to count emotes' });
@@ -21,21 +21,9 @@ const { validate } = require('openvibe-contracts');
@@ -21,21 +21,9 @@ const { validate } = require('openvibe-contracts');
const db = require('../db/database');
const db = require('../db/database');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-let ready = false;
-function ensureSchema() {
- if (ready) return;
- db.getDb().exec(`CREATE TABLE IF NOT EXISTS network_blocks (
- blocker_subject TEXT NOT NULL,
- blocked_subject TEXT NOT NULL,
- active INTEGER NOT NULL,
- revision INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- PRIMARY KEY (blocker_subject, blocked_subject)
- );
- CREATE INDEX IF NOT EXISTS idx_network_blocks_blocked ON network_blocks(blocked_subject, active);`);
- ready = true;
-}
+// The table is in migrations/0001_initial.sql (PostgreSQL); nothing is created at runtime. Kept for callers.
+function ensureSchema() {}
/** The payload of a network.block.changed envelope from Network, or null when it is not one. */
/** The payload of a network.block.changed envelope from Network, or null when it is not one. */
function payloadOf(event) {
function payloadOf(event) {
@@ -46,48 +34,48 @@ function payloadOf(event) {
@@ -46,48 +34,48 @@ function payloadOf(event) {
}
}
/** Apply one change when it is newer than what is kept for its pair. → true when the projection moved. */
/** Apply one change when it is newer than what is kept for its pair. → true when the projection moved. */
-function apply(p, now = Date.now()) {
+async function apply(p, now = Date.now()) {
ensureSchema();
ensureSchema();
- const r = db.run(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)
+ const r = await db.run(`INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at
ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at
WHERE excluded.revision > network_blocks.revision`, [p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now]);
WHERE excluded.revision > network_blocks.revision`, [p.blocker, p.blocked, p.active ? 1 : 0, p.revision, now]);
return r.changes > 0;
return r.changes > 0;
}
}
/** Did `blocker` block `blocked` (subjects)? */
/** Did `blocker` block `blocked` (subjects)? */
-function hasBlocked(blocker, blocked) {
+async function hasBlocked(blocker, blocked) {
if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || ''))) return false;
if (!SUBJECT_RE.test(String(blocker || '')) || !SUBJECT_RE.test(String(blocked || ''))) return false;
ensureSchema();
ensureSchema();
- return !!db.get('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1', [blocker, blocked]);
+ return !!await db.get('SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1', [blocker, blocked]);
}
}
/** Did either of two subjects block the other? */
/** Did either of two subjects block the other? */
-function eitherBlocked(a, b) {
- return hasBlocked(a, b) || hasBlocked(b, a);
+async function eitherBlocked(a, b) {
+ return await hasBlocked(a, b) || await hasBlocked(b, a);
}
}
/** The same, for two Live user ids (through their Network subjects). */
/** The same, for two Live user ids (through their Network subjects). */
-function eitherBlockedUsers(userIdA, userIdB) {
- return eitherBlocked(db.subjectFor(userIdA), db.subjectFor(userIdB));
+async function eitherBlockedUsers(userIdA, userIdB) {
+ return await eitherBlocked(await db.subjectFor(userIdA), await db.subjectFor(userIdB));
}
}
/** The subjects who blocked `blocked` (a subject), active blocks only. One indexed read. */
/** The subjects who blocked `blocked` (a subject), active blocks only. One indexed read. */
-function blockersOf(blocked) {
+async function blockersOf(blocked) {
if (!SUBJECT_RE.test(String(blocked || ''))) return [];
if (!SUBJECT_RE.test(String(blocked || ''))) return [];
ensureSchema();
ensureSchema();
- return db.all('SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1', [blocked]).map((r) => r.blocker_subject);
+ return (await db.all('SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1', [blocked])).map((r) => r.blocker_subject);
}
}
/** The Live user ids a signed-in reader blocked (through their subjects, the ctx_users projection). */
/** The Live user ids a signed-in reader blocked (through their subjects, the ctx_users projection). */
-function blockedUserIds(user) {
- const subject = user && (user.subject_id || db.subjectFor(user.id));
+async function blockedUserIds(user) {
+ const subject = user && (user.subject_id || await db.subjectFor(user.id));
if (!SUBJECT_RE.test(String(subject || ''))) return new Set();
if (!SUBJECT_RE.test(String(subject || ''))) return new Set();
ensureSchema();
ensureSchema();
- return new Set(db.all(`SELECT u.id FROM network_blocks b JOIN ctx_users u ON u.subject_id = b.blocked_subject
- WHERE b.blocker_subject = ? AND b.active = 1`, [subject]).map((r) => Number(r.id)));
+ return new Set((await db.all(`SELECT u.id FROM network_blocks b JOIN ctx_users u ON u.subject_id = b.blocked_subject
+ WHERE b.blocker_subject = ? AND b.active = 1`, [subject])).map((r) => Number(r.id)));
}
}
-/** For tests: forget that the table was created (the table stays). */
-function _reset() { ready = false; }
+/** For tests (nothing is cached). */
+function _reset() {}
module.exports = { ensureSchema, payloadOf, apply, hasBlocked, eitherBlocked, eitherBlockedUsers, blockersOf, blockedUserIds, _reset };
module.exports = { ensureSchema, payloadOf, apply, hasBlocked, eitherBlocked, eitherBlockedUsers, blockersOf, blockedUserIds, _reset };
@@ -25,10 +25,10 @@ const { limits } = require('../net/actor-limits');
@@ -25,10 +25,10 @@ const { limits } = require('../net/actor-limits');
* their own view: network-blocks.js). Pages and cursor reads alike; latest_id and deleted_ids stay
* their own view: network-blocks.js). Pages and cursor reads alike; latest_id and deleted_ids stay
* the room's, so the reader's cursor moves on as everyone's does. Such answers are per reader.
* the room's, so the reader's cursor moves on as everyone's does. Such answers are per reader.
*/
*/
-function withoutBlocked(req, res, rows) {
+async function withoutBlocked(req, res, rows) {
if (!req.user) return rows;
if (!req.user) return rows;
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
- const hidden = networkBlocks.blockedUserIds(req.user);
+ const hidden = await networkBlocks.blockedUserIds(req.user);
if (!hidden.size) return rows;
if (!hidden.size) return rows;
return rows.filter((m) => !(m.user_id && hidden.has(Number(m.user_id))));
return rows.filter((m) => !(m.user_id && hidden.has(Number(m.user_id))));
}
}
@@ -48,9 +48,9 @@ const exportLimit = limits('chat.export', { minute: 5, hour: 30 });
@@ -48,9 +48,9 @@ const exportLimit = limits('chat.export', { minute: 5, hour: 30 });
// A staff member viewing, searching or exporting someone else's chat logs is recorded like any other
// A staff member viewing, searching or exporting someone else's chat logs is recorded like any other
// moderation action (moderation_actions → chat.moderation.action → the network audit log, ADR-022).
// moderation action (moderation_actions → chat.moderation.action → the network audit log, ADR-022).
// A person reading their own lines, or a streamer their own stream's, is not.
// A person reading their own lines, or a streamer their own stream's, is not.
-function auditLogAccess(req, action_type, { scope_type = 'site', scope_id = null, target_user_id = null, details = {} } = {}) {
+async function auditLogAccess(req, action_type, { scope_type = 'site', scope_id = null, target_user_id = null, details = {} } = {}) {
try {
try {
- db.logModerationAction({ scope_type, scope_id, actor_user_id: req.user.id, target_user_id, action_type, details });
+ await db.logModerationAction({ scope_type, scope_id, actor_user_id: req.user.id, target_user_id, action_type, details });
} catch (err) {
} catch (err) {
console.warn('[Chat] log access audit failed:', err.message);
console.warn('[Chat] log access audit failed:', err.message);
}
}
@@ -246,16 +246,16 @@ router.get('/gif/search', optionalAuth, gifLimit, async (req, res) => {
@@ -246,16 +246,16 @@ router.get('/gif/search', optionalAuth, gifLimit, async (req, res) => {
* Each row must have reply_to_id (from DB). Adds a reply_to object
* Each row must have reply_to_id (from DB). Adds a reply_to object
* with { id, username, user_id, message } for the parent message.
* with { id, username, user_id, message } for the parent message.
*/
*/
-function hydrateReplies(messages) {
+async function hydrateReplies(messages) {
const replyIds = [...new Set(messages.map(m => m.reply_to_id).filter(Boolean))];
const replyIds = [...new Set(messages.map(m => m.reply_to_id).filter(Boolean))];
if (!replyIds.length) return messages;
if (!replyIds.length) return messages;
const placeholders = replyIds.map(() => '?').join(',');
const placeholders = replyIds.map(() => '?').join(',');
- const parents = db.all(
+ const parents = await db.all(
`SELECT id, username, user_id, message
`SELECT id, username, user_id, message
FROM chat_messages
FROM chat_messages
WHERE id IN (${placeholders})
WHERE id IN (${placeholders})
AND is_deleted = 0
AND is_deleted = 0
- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,
+ AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > ov_now())`,
replyIds
replyIds
);
);
const parentMap = new Map(parents.map(p => [p.id, p]));
const parentMap = new Map(parents.map(p => [p.id, p]));
@@ -313,7 +313,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
@@ -313,7 +313,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
const replyToId = req.body.reply_to_id ? parseInt(req.body.reply_to_id) : null;
const replyToId = req.body.reply_to_id ? parseInt(req.body.reply_to_id) : null;
let replyTo = null;
let replyTo = null;
if (replyToId) {
if (replyToId) {
- const parent = db.getChatMessageById(replyToId);
+ const parent = await db.getChatMessageById(replyToId);
if (parent && !parent.is_deleted) {
if (parent && !parent.is_deleted) {
replyTo = {
replyTo = {
id: parent.id,
id: parent.id,
@@ -353,7 +353,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
@@ -353,7 +353,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
// Save to database
// Save to database
let savedId = null;
let savedId = null;
try {
try {
- const result = db.saveChatMessage({
+ const result = await db.saveChatMessage({
stream_id: null,
stream_id: null,
user_id: req.user.id,
user_id: req.user.id,
anon_id: null,
anon_id: null,
@@ -372,7 +372,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
@@ -372,7 +372,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
if (replyTo) chatMsg.reply_to = replyTo;
if (replyTo) chatMsg.reply_to = replyTo;
// Broadcast to all global chat clients
// Broadcast to all global chat clients
- chatServer.broadcastGlobal(chatMsg);
+ await chatServer.broadcastGlobal(chatMsg);
res.json({ ok: true });
res.json({ ok: true });
} catch (err) {
} catch (err) {
console.error('[Chat] REST send error:', err.message);
console.error('[Chat] REST send error:', err.message);
@@ -381,7 +381,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
@@ -381,7 +381,7 @@ router.post('/send', requireAuth, limits('chat.message.send', { minute: 20, hour
});
});
// ── Search Chat Messages (admin or self) ─────────────────────
// ── Search Chat Messages (admin or self) ─────────────────────
-router.get('/search', requireAuth, searchLimit, (req, res) => {
+router.get('/search', requireAuth, searchLimit, async (req, res) => {
try {
try {
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const offset = parseInt(req.query.offset || '0');
const offset = parseInt(req.query.offset || '0');
@@ -392,11 +392,11 @@ router.get('/search', requireAuth, searchLimit, (req, res) => {
@@ -392,11 +392,11 @@ router.get('/search', requireAuth, searchLimit, (req, res) => {
// Admin / global_mod can search anyone; others search only their own
// Admin / global_mod can search anyone; others search only their own
const effectiveUserId = permissions.canViewOtherUserLogs(req.user) ? userId : req.user.id;
const effectiveUserId = permissions.canViewOtherUserLogs(req.user) ? userId : req.user.id;
- const result = db.searchChatMessages({
+ const result = await db.searchChatMessages({
query, userId: effectiveUserId, streamId, limit, offset,
query, userId: effectiveUserId, streamId, limit, offset,
});
});
if (effectiveUserId !== req.user.id) {
if (effectiveUserId !== req.user.id) {
- auditLogAccess(req, 'chat_log_search', { scope_type: streamId ? 'stream' : 'site', scope_id: streamId, target_user_id: effectiveUserId,
+ await auditLogAccess(req, 'chat_log_search', { scope_type: streamId ? 'stream' : 'site', scope_id: streamId, target_user_id: effectiveUserId,
details: { query: String(query).slice(0, 100), results: result.messages.length } });
details: { query: String(query).slice(0, 100), results: result.messages.length } });
}
}
publicRows(result.messages);
publicRows(result.messages);
@@ -408,7 +408,7 @@ router.get('/search', requireAuth, searchLimit, (req, res) => {
@@ -408,7 +408,7 @@ router.get('/search', requireAuth, searchLimit, (req, res) => {
});
});
// ── User Chat History ────────────────────────────────────────
// ── User Chat History ────────────────────────────────────────
-router.get('/user/:userId/history', requireAuth, (req, res) => {
+router.get('/user/:userId/history', requireAuth, async (req, res) => {
try {
try {
const userId = parseInt(req.params.userId);
const userId = parseInt(req.params.userId);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
@@ -419,8 +419,8 @@ router.get('/user/:userId/history', requireAuth, (req, res) => {
@@ -419,8 +419,8 @@ router.get('/user/:userId/history', requireAuth, (req, res) => {
return res.status(403).json({ error: 'Access denied' });
return res.status(403).json({ error: 'Access denied' });
}
}
- const result = db.getUserChatHistory(userId, limit, offset);
- if (userId !== req.user.id && offset === 0) auditLogAccess(req, 'chat_log_view', { target_user_id: userId, details: { total: result.total } });
+ const result = await db.getUserChatHistory(userId, limit, offset);
+ if (userId !== req.user.id && offset === 0) await auditLogAccess(req, 'chat_log_view', { target_user_id: userId, details: { total: result.total } });
publicRows(result.messages);
publicRows(result.messages);
res.json(result);
res.json(result);
} catch (err) {
} catch (err) {
@@ -442,9 +442,9 @@ router.get('/user/:username/profile', optionalAuth, async (req, res) => {
@@ -442,9 +442,9 @@ router.get('/user/:username/profile', optionalAuth, async (req, res) => {
});
});
// ── Chat-relay (external) user info: join date (first message) etc. ──
// ── Chat-relay (external) user info: join date (first message) etc. ──
-router.get('/relay-user/:platform/:username', optionalAuth, (req, res) => {
+router.get('/relay-user/:platform/:username', optionalAuth, async (req, res) => {
try {
try {
- const r = db.getRelayUser(req.params.platform, req.params.username);
+ const r = await db.getRelayUser(req.params.platform, req.params.username);
res.json({ relayUser: r || null });
res.json({ relayUser: r || null });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to get relay user' });
res.status(500).json({ error: 'Failed to get relay user' });
@@ -459,14 +459,14 @@ router.get('/anon/:anonId', optionalAuth, async (req, res) => {
@@ -459,14 +459,14 @@ router.get('/anon/:anonId', optionalAuth, async (req, res) => {
// When the number was assigned is Live's (anon_ip_mappings); the chat counts are ours.
// When the number was assigned is Live's (anon_ip_mappings); the chat counts are ours.
const num = db.anonSubjectId(anonId);
const num = db.anonSubjectId(anonId);
const firstSeen = num ? await ctx.anonFirstSeenByNum(num) : null;
const firstSeen = num ? await ctx.anonFirstSeenByNum(num) : null;
- res.json({ anon: db.getAnonMeta(anonId, firstSeen) });
+ res.json({ anon: await db.getAnonMeta(anonId, firstSeen) });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to get anon info' });
res.status(500).json({ error: 'Failed to get anon info' });
}
}
});
});
// An anonymous chatter's chat-message history — same gate as native logs.
// An anonymous chatter's chat-message history — same gate as native logs.
-router.get('/anon/:anonId/logs', requireAuth, (req, res) => {
+router.get('/anon/:anonId/logs', requireAuth, async (req, res) => {
try {
try {
if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
const anonId = String(req.params.anonId || '');
const anonId = String(req.params.anonId || '');
@@ -474,7 +474,7 @@ router.get('/anon/:anonId/logs', requireAuth, (req, res) => {
@@ -474,7 +474,7 @@ router.get('/anon/:anonId/logs', requireAuth, (req, res) => {
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
const query = String(req.query.q || '').trim();
const query = String(req.query.q || '').trim();
- const r = db.getAnonChatHistory(anonId, { limit, offset, query });
+ const r = await db.getAnonChatHistory(anonId, { limit, offset, query });
res.json(r);
res.json(r);
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to get anon user logs' });
res.status(500).json({ error: 'Failed to get anon user logs' });
@@ -482,13 +482,13 @@ router.get('/anon/:anonId/logs', requireAuth, (req, res) => {
@@ -482,13 +482,13 @@ router.get('/anon/:anonId/logs', requireAuth, (req, res) => {
});
});
// A relay (external-platform) user's chat-message history — same gate as native logs.
// A relay (external-platform) user's chat-message history — same gate as native logs.
-router.get('/relay-user/:platform/:username/logs', requireAuth, (req, res) => {
+router.get('/relay-user/:platform/:username/logs', requireAuth, async (req, res) => {
try {
try {
if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
const query = String(req.query.q || '').trim();
const query = String(req.query.q || '').trim();
- const r = db.getRelayUserChatHistory(req.params.platform, req.params.username, { limit, offset, query });
+ const r = await db.getRelayUserChatHistory(req.params.platform, req.params.username, { limit, offset, query });
res.json(r);
res.json(r);
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to get relay user logs' });
res.status(500).json({ error: 'Failed to get relay user logs' });
@@ -513,11 +513,11 @@ router.get('/global/history', optionalAuth, async (req, res) => {
@@ -513,11 +513,11 @@ router.get('/global/history', optionalAuth, async (req, res) => {
// after_id = cursor read: only what the caller has not seen (oldest→newest, PK range scan).
// after_id = cursor read: only what the caller has not seen (oldest→newest, PK range scan).
// Anything else = a page: the newest rows, optionally before a timestamp.
// Anything else = a page: the newest rows, optionally before a timestamp.
const out = afterId != null
const out = afterId != null
- ? historyStore.delta('global', { afterId, limit, channelUsername })
- : historyStore.page('global', { limit, before, channelUsername });
- out.messages = withoutBlocked(req, res, out.messages);
+ ? await historyStore.delta('global', { afterId, limit, channelUsername })
+ : await historyStore.page('global', { limit, before, channelUsername });
+ out.messages = await withoutBlocked(req, res, out.messages);
await loadDecor(out.messages);
await loadDecor(out.messages);
- out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));
+ out.messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(out.messages.map((x) => ({ ...x })))));
res.json(out);
res.json(out);
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to get global chat history' });
res.status(500).json({ error: 'Failed to get global chat history' });
@@ -525,7 +525,7 @@ router.get('/global/history', optionalAuth, async (req, res) => {
@@ -525,7 +525,7 @@ router.get('/global/history', optionalAuth, async (req, res) => {
});
});
// ── Chat Replay (for VOD/clip playback sync) ────────────────
// ── Chat Replay (for VOD/clip playback sync) ────────────────
-router.get('/:streamId/replay', optionalAuth, (req, res) => {
+router.get('/:streamId/replay', optionalAuth, async (req, res) => {
try {
try {
const streamId = parseInt(req.params.streamId);
const streamId = parseInt(req.params.streamId);
if (!streamId) return res.status(400).json({ error: 'Invalid stream ID' });
if (!streamId) return res.status(400).json({ error: 'Invalid stream ID' });
@@ -533,7 +533,7 @@ router.get('/:streamId/replay', optionalAuth, (req, res) => {
@@ -533,7 +533,7 @@ router.get('/:streamId/replay', optionalAuth, (req, res) => {
const from = req.query.from || null; // ISO timestamp
const from = req.query.from || null; // ISO timestamp
const to = req.query.to || null; // ISO timestamp
const to = req.query.to || null; // ISO timestamp
- const messages = publicRows(db.getChatReplay(streamId, from, to));
+ const messages = publicRows(await db.getChatReplay(streamId, from, to));
res.json({ messages });
res.json({ messages });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to get chat replay' });
res.status(500).json({ error: 'Failed to get chat replay' });
@@ -580,7 +580,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
@@ -580,7 +580,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
LEFT JOIN ctx_users bu ON s.user_id = bu.id
LEFT JOIN ctx_users bu ON s.user_id = bu.id
LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id
LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id
WHERE cm.channel_user_id = ? AND cm.is_deleted = 0
WHERE cm.channel_user_id = ? AND cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
+ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;
params.push(broadcasterId);
params.push(broadcasterId);
} else {
} else {
sql = `${select}
sql = `${select}
@@ -591,7 +591,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
@@ -591,7 +591,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
LEFT JOIN ctx_users bu ON s.user_id = bu.id
LEFT JOIN ctx_users bu ON s.user_id = bu.id
LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id
LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id
WHERE cm.stream_id = ? AND cm.is_deleted = 0
WHERE cm.stream_id = ? AND cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
+ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;
params.push(req.params.streamId);
params.push(req.params.streamId);
}
}
@@ -610,13 +610,13 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
@@ -610,13 +610,13 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
params.push(limit);
params.push(limit);
}
}
- let rows = db.all(sql, params);
+ let rows = await db.all(sql, params);
let complete = true;
let complete = true;
if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();
if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();
const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);
const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);
- rows = withoutBlocked(req, res, rows);
+ rows = await withoutBlocked(req, res, rows);
await loadDecor(rows);
await loadDecor(rows);
- const messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(rows)));
+ const messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(rows)));
// Currently-live slots for this broadcaster — lets the client render a
// Currently-live slots for this broadcaster — lets the client render a
// "hop between live streams" affordance.
// "hop between live streams" affordance.
@@ -624,9 +624,9 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
@@ -624,9 +624,9 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
let channel = null;
let channel = null;
if (broadcasterId) {
if (broadcasterId) {
try {
try {
- const owner = ctx.getUserById(broadcasterId);
+ const owner = await ctx.getUserById(broadcasterId);
channel = owner ? owner.username : null;
channel = owner ? owner.username : null;
- liveSlots = (ctx.getManagedStreamsByUserId(broadcasterId) || [])
+ liveSlots = (await ctx.getManagedStreamsByUserId(broadcasterId) || [])
.filter(ms => ms.is_currently_live)
.filter(ms => ms.is_currently_live)
.map(ms => ({
.map(ms => ({
managed_stream_id: ms.id,
managed_stream_id: ms.id,
@@ -637,7 +637,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
@@ -637,7 +637,7 @@ router.get('/:streamId/history', optionalAuth, async (req, res) => {
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
// A cursor read also names the rows at or under the cursor deleted since (history-store.js).
// A cursor read also names the rows at or under the cursor deleted since (history-store.js).
- const deleted = afterId != null ? { deleted_ids: historyStore.deletedIds(spanStreamer ? `channel:${broadcasterId}` : `stream:${parseInt(req.params.streamId, 10) || 0}`, afterId) } : {};
+ const deleted = afterId != null ? { deleted_ids: await historyStore.deletedIds(spanStreamer ? `channel:${broadcasterId}` : `stream:${parseInt(req.params.streamId, 10) || 0}`, afterId) } : {};
res.json({
res.json({
messages, latest_id, complete, ...deleted, liveSlots, channel,
messages, latest_id, complete, ...deleted, liveSlots, channel,
activeStreamId: parseInt(req.params.streamId) || null,
activeStreamId: parseInt(req.params.streamId) || null,
@@ -657,16 +657,16 @@ router.get('/channel/:userId/history', optionalAuth, async (req, res) => {
@@ -657,16 +657,16 @@ router.get('/channel/:userId/history', optionalAuth, async (req, res) => {
if (!userId) return res.status(400).json({ error: 'Bad user id' });
if (!userId) return res.status(400).json({ error: 'Bad user id' });
const room = `channel:${userId}`;
const room = `channel:${userId}`;
const out = req.query.after_id != null
const out = req.query.after_id != null
- ? historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit })
- : historyStore.page(room, { limit: req.query.limit, before: req.query.before });
- out.messages = withoutBlocked(req, res, out.messages);
+ ? await historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit })
+ : await historyStore.page(room, { limit: req.query.limit, before: req.query.before });
+ out.messages = await withoutBlocked(req, res, out.messages);
await loadDecor(out.messages);
await loadDecor(out.messages);
- out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));
+ out.messages = publicRows(enrichMessagesWithCosmetics(await hydrateReplies(out.messages.map((x) => ({ ...x })))));
let liveSlots = [], channel = null;
let liveSlots = [], channel = null;
try {
try {
- const owner = ctx.getUserById(userId);
+ const owner = await ctx.getUserById(userId);
channel = owner ? owner.username : null;
channel = owner ? owner.username : null;
- liveSlots = (ctx.getManagedStreamsByUserId(userId) || [])
+ liveSlots = (await ctx.getManagedStreamsByUserId(userId) || [])
.filter(ms => ms.is_currently_live)
.filter(ms => ms.is_currently_live)
.map(ms => ({ managed_stream_id: ms.id, slug: ms.slug, title: ms.title, live_session_id: ms.live_session_id }));
.map(ms => ({ managed_stream_id: ms.id, slug: ms.slug, title: ms.title, live_session_id: ms.live_session_id }));
} catch { /* non-critical */ }
} catch { /* non-critical */ }
@@ -718,7 +718,7 @@ router.post('/admin/purge/preview', requireAuth, searchLimit, async (req, res) =
@@ -718,7 +718,7 @@ router.post('/admin/purge/preview', requireAuth, searchLimit, async (req, res) =
}
}
} else {
} else {
// Non-admin without streamId: scope to their most recent stream
// Non-admin without streamId: scope to their most recent stream
- const userStreams = ctx.getStreamsByUserId(req.user.id, 1);
+ const userStreams = await ctx.getStreamsByUserId(req.user.id, 1);
if (!userStreams?.length) {
if (!userStreams?.length) {
return res.json({ count: 0 });
return res.json({ count: 0 });
}
}
@@ -726,7 +726,7 @@ router.post('/admin/purge/preview', requireAuth, searchLimit, async (req, res) =
@@ -726,7 +726,7 @@ router.post('/admin/purge/preview', requireAuth, searchLimit, async (req, res) =
}
}
}
}
- const count = db.countChatMessagesByTimeRange(effectiveStreamId, from, to);
+ const count = await db.countChatMessagesByTimeRange(effectiveStreamId, from, to);
res.json({ count });
res.json({ count });
} catch (e) {
} catch (e) {
console.error('[Chat] Purge preview error:', e.message);
console.error('[Chat] Purge preview error:', e.message);
@@ -749,7 +749,7 @@ router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, ho
@@ -749,7 +749,7 @@ router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, ho
}
}
} else {
} else {
// Non-admin without streamId: scope to their most recent stream
// Non-admin without streamId: scope to their most recent stream
- const userStreams = ctx.getStreamsByUserId(req.user.id, 1);
+ const userStreams = await ctx.getStreamsByUserId(req.user.id, 1);
if (!userStreams?.length) {
if (!userStreams?.length) {
return res.json({ deleted: 0 });
return res.json({ deleted: 0 });
}
}
@@ -757,7 +757,7 @@ router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, ho
@@ -757,7 +757,7 @@ router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, ho
}
}
}
}
- const result = db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);
+ const result = await db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);
// Every surface that showed those lines: the stream's sockets get the range (Live's chat.js
// Every surface that showed those lines: the stream's sockets get the range (Live's chat.js
// drops it by time, with its "purged" notice) and, by id, the rest of the channel room (other
// drops it by time, with its "purged" notice) and, by id, the rest of the channel room (other
@@ -769,15 +769,15 @@ router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, ho
@@ -769,15 +769,15 @@ router.delete('/admin/purge', requireAuth, limits('chat.purge', { minute: 10, ho
const ids = (result && result.ids) || [];
const ids = (result && result.ids) || [];
const gone = { type: 'delete-messages', ids };
const gone = { type: 'delete-messages', ids };
if (effectiveStreamId) {
if (effectiveStreamId) {
- chatServer.broadcastToStream(effectiveStreamId, payload);
+ await chatServer.broadcastToStream(effectiveStreamId, payload);
if (ids.length) {
if (ids.length) {
- const ownerId = ctx.getStreamById(effectiveStreamId)?.user_id || null;
- chatServer.broadcastToChannelRoom(ownerId, effectiveStreamId, gone);
- chatServer.forwardToGlobal(effectiveStreamId, gone);
+ const ownerId = (await ctx.getStreamById(effectiveStreamId))?.user_id || null;
+ await chatServer.broadcastToChannelRoom(ownerId, effectiveStreamId, gone);
+ await chatServer.forwardToGlobal(effectiveStreamId, gone);
}
}
} else {
} else {
- chatServer.broadcastGlobal(payload);
- if (ids.length) chatServer.broadcastGlobal(gone);
+ await chatServer.broadcastGlobal(payload);
+ if (ids.length) await chatServer.broadcastGlobal(gone);
}
}
} catch { /* chat server may not be initialized */ }
} catch { /* chat server may not be initialized */ }
@@ -804,7 +804,7 @@ router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {
@@ -804,7 +804,7 @@ router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {
}
}
} else {
} else {
// Non-admin without streamId: scope to their most recent stream
// Non-admin without streamId: scope to their most recent stream
- const userStreams = ctx.getStreamsByUserId(req.user.id, 1);
+ const userStreams = await ctx.getStreamsByUserId(req.user.id, 1);
if (!userStreams?.length) {
if (!userStreams?.length) {
return res.json({ rows: [], total: 0, page: 1, limit: 50, totalPages: 0 });
return res.json({ rows: [], total: 0, page: 1, limit: 50, totalPages: 0 });
}
}
@@ -812,7 +812,7 @@ router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {
@@ -812,7 +812,7 @@ router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {
}
}
}
}
- const result = db.getChatLogs({
+ const result = await db.getChatLogs({
streamId: effectiveStreamId,
streamId: effectiveStreamId,
username, search, from, to, messageType,
username, search, from, to, messageType,
page: parseInt(page) || 1,
page: parseInt(page) || 1,
@@ -821,8 +821,8 @@ router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {
@@ -821,8 +821,8 @@ router.get('/admin/logs', requireAuth, searchLimit, async (req, res) => {
});
});
// Staff browsing beyond their own stream: the first page of each view is recorded.
// Staff browsing beyond their own stream: the first page of each view is recorded.
if (permissions.can(req.user, 'staff.moderation.purge') && (parseInt(page) || 1) === 1) {
if (permissions.can(req.user, 'staff.moderation.purge') && (parseInt(page) || 1) === 1) {
- const own = effectiveStreamId && (ctx.getStreamsByUserId(req.user.id, 50) || []).some((st) => st.id === effectiveStreamId);
- if (!own) auditLogAccess(req, 'chat_log_view', { scope_type: effectiveStreamId ? 'stream' : 'site', scope_id: effectiveStreamId || null,
+ const own = effectiveStreamId && (await ctx.getStreamsByUserId(req.user.id, 50) || []).some((st) => st.id === effectiveStreamId);
+ if (!own) await auditLogAccess(req, 'chat_log_view', { scope_type: effectiveStreamId ? 'stream' : 'site', scope_id: effectiveStreamId || null,
details: { username: username || null, search: search ? String(search).slice(0, 100) : null, from: from || null, to: to || null, include_deleted: includeDeleted === 'true', total: result.total } });
details: { username: username || null, search: search ? String(search).slice(0, 100) : null, from: from || null, to: to || null, include_deleted: includeDeleted === 'true', total: result.total } });
}
}
publicRows(result.rows);
publicRows(result.rows);
@@ -850,14 +850,14 @@ router.get('/admin/logs/export', requireAuth, exportLimit, async (req, res) => {
@@ -850,14 +850,14 @@ router.get('/admin/logs/export', requireAuth, exportLimit, async (req, res) => {
}
}
// Get all matching rows (up to 50k)
// Get all matching rows (up to 50k)
- const result = db.getChatLogs({
+ const result = await db.getChatLogs({
streamId: streamId ? parseInt(streamId) : undefined,
streamId: streamId ? parseInt(streamId) : undefined,
username, search, from, to, messageType,
username, search, from, to, messageType,
page: 1, limit: 50000,
page: 1, limit: 50000,
});
});
if (permissions.can(req.user, 'staff.moderation.purge')) {
if (permissions.can(req.user, 'staff.moderation.purge')) {
- const own = streamId && (ctx.getStreamsByUserId(req.user.id, 50) || []).some((st) => st.id === parseInt(streamId));
- if (!own) auditLogAccess(req, 'chat_log_export', { scope_type: streamId ? 'stream' : 'site', scope_id: streamId ? parseInt(streamId) : null,
+ const own = streamId && (await ctx.getStreamsByUserId(req.user.id, 50) || []).some((st) => st.id === parseInt(streamId));
+ if (!own) await auditLogAccess(req, 'chat_log_export', { scope_type: streamId ? 'stream' : 'site', scope_id: streamId ? parseInt(streamId) : null,
details: { format: format === 'csv' ? 'csv' : 'json', rows: result.rows.length, username: username || null, search: search ? String(search).slice(0, 100) : null, from: from || null, to: to || null } });
details: { format: format === 'csv' ? 'csv' : 'json', rows: result.rows.length, username: username || null, search: search ? String(search).slice(0, 100) : null, from: from || null, to: to || null } });
}
}
publicRows(result.rows);
publicRows(result.rows);
@@ -882,13 +882,13 @@ router.get('/admin/logs/export', requireAuth, exportLimit, async (req, res) => {
@@ -882,13 +882,13 @@ router.get('/admin/logs/export', requireAuth, exportLimit, async (req, res) => {
// ── Your own chat, to keep (WS-I task 7) ─────────────────────────────────────
// ── Your own chat, to keep (WS-I task 7) ─────────────────────────────────────
// Every message you sent that is still visible (not deleted, not expired), newest first, as JSON or
// Every message you sent that is still visible (not deleted, not expired), newest first, as JSON or
// CSV. Up to 100,000 lines; `truncated` says when there were more.
// CSV. Up to 100,000 lines; `truncated` says when there were more.
-router.get('/me/export', requireAuth, exportLimit, (req, res) => {
+router.get('/me/export', requireAuth, exportLimit, async (req, res) => {
try {
try {
const MAX = 100000, PAGE = 5000;
const MAX = 100000, PAGE = 5000;
const rows = [];
const rows = [];
let total = 0;
let total = 0;
for (let offset = 0; offset < MAX; offset += PAGE) {
for (let offset = 0; offset < MAX; offset += PAGE) {
- const r = db.getUserChatHistory(req.user.id, PAGE, offset);
+ const r = await db.getUserChatHistory(req.user.id, PAGE, offset);
total = r.total;
total = r.total;
rows.push(...r.messages);
rows.push(...r.messages);
if (r.messages.length < PAGE) break;
if (r.messages.length < PAGE) break;
@@ -148,11 +148,11 @@ function serializeSound(s) {
@@ -148,11 +148,11 @@ function serializeSound(s) {
}
}
// ── List a channel's sounds ──────────────────────────────────
// ── List a channel's sounds ──────────────────────────────────
-router.get('/channel/:userId', (req, res) => {
+router.get('/channel/:userId', async (req, res) => {
try {
try {
const userId = parseInt(req.params.userId);
const userId = parseInt(req.params.userId);
if (!userId) return res.json({ sounds: [] });
if (!userId) return res.json({ sounds: [] });
- res.json({ sounds: db.getChannelSounds(userId).map(serializeSound) });
+ res.json({ sounds: (await db.getChannelSounds(userId)).map(serializeSound) });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to load channel sounds' });
res.status(500).json({ error: 'Failed to load channel sounds' });
}
}
@@ -163,7 +163,7 @@ router.get('/all/:streamId', async (req, res) => {
@@ -163,7 +163,7 @@ router.get('/all/:streamId', async (req, res) => {
try {
try {
const stream = await ctx.ensureStream(parseInt(req.params.streamId));
const stream = await ctx.ensureStream(parseInt(req.params.streamId));
if (!stream?.user_id) return res.json({ sounds: [] });
if (!stream?.user_id) return res.json({ sounds: [] });
- res.json({ sounds: db.getChannelSounds(stream.user_id).map(serializeSound) });
+ res.json({ sounds: (await db.getChannelSounds(stream.user_id)).map(serializeSound) });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to load sounds' });
res.status(500).json({ error: 'Failed to load sounds' });
}
}
@@ -198,8 +198,8 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r
@@ -198,8 +198,8 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r
}
}
await ctx.ensurePolicy(channel.id);
await ctx.ensurePolicy(channel.id);
- const settings = ctx.getChannelModerationSettings(channel.id);
- const isMod = permissions.canModerateChannel(req.user, channel.id);
+ const settings = await ctx.getChannelModerationSettings(channel.id);
+ const isMod = await permissions.canModerateChannel(req.user, channel.id);
const isOwnChannel = channelOwnerId === req.user.id;
const isOwnChannel = channelOwnerId === req.user.id;
if (!isMod && !settings.custom_sounds_enabled) {
if (!isMod && !settings.custom_sounds_enabled) {
@@ -213,18 +213,18 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r
@@ -213,18 +213,18 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r
// Adding a NEW file to an EXISTING command is restricted to that command's
// Adding a NEW file to an EXISTING command is restricted to that command's
// creator (or channel mods/owner), so viewers can't hijack someone's command.
// creator (or channel mods/owner), so viewers can't hijack someone's command.
- const existingForCmd = db.getChannelSoundByCommand(channelOwnerId, command);
+ const existingForCmd = await db.getChannelSoundByCommand(channelOwnerId, command);
if (existingForCmd && !isMod && !isOwnChannel && existingForCmd.created_by !== req.user.id) {
if (existingForCmd && !isMod && !isOwnChannel && existingForCmd.created_by !== req.user.id) {
cleanup();
cleanup();
return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can add more sounds to it.` });
return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can add more sounds to it.` });
}
}
// Per-channel + per-uploader caps
// Per-channel + per-uploader caps
- if (db.countChannelSounds(channelOwnerId) >= config.sounds.maxPerChannel) {
+ if (await db.countChannelSounds(channelOwnerId) >= config.sounds.maxPerChannel) {
cleanup();
cleanup();
return res.status(400).json({ error: `This channel has reached its sound limit (${config.sounds.maxPerChannel}).` });
return res.status(400).json({ error: `This channel has reached its sound limit (${config.sounds.maxPerChannel}).` });
}
}
- if (!isMod && db.countChannelSoundsByUploader(channelOwnerId, req.user.id) >= config.sounds.maxPerUploaderPerChannel) {
+ if (!isMod && await db.countChannelSoundsByUploader(channelOwnerId, req.user.id) >= config.sounds.maxPerUploaderPerChannel) {
cleanup();
cleanup();
return res.status(400).json({ error: `You've reached your upload limit for this channel (${config.sounds.maxPerUploaderPerChannel}).` });
return res.status(400).json({ error: `You've reached your upload limit for this channel (${config.sounds.maxPerUploaderPerChannel}).` });
}
}
@@ -262,7 +262,7 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r
@@ -262,7 +262,7 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r
// Emote attached to the command (per-command; new files inherit the existing one).
// Emote attached to the command (per-command; new files inherit the existing one).
const emoteCode = String(req.body.emote_code || (existingForCmd && existingForCmd.emote_code) || '').trim().slice(0, 32);
const emoteCode = String(req.body.emote_code || (existingForCmd && existingForCmd.emote_code) || '').trim().slice(0, 32);
- const result = db.createChannelSound({
+ const result = await db.createChannelSound({
channel_owner_id: channelOwnerId,
channel_owner_id: channelOwnerId,
command,
command,
url: finalPath,
url: finalPath,
@@ -273,12 +273,12 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r
@@ -273,12 +273,12 @@ router.post('/', requireAuth, uploadLimit, soundUpload.single('sound'), async (r
emote_code: emoteCode,
emote_code: emoteCode,
});
});
// If an emote was (re)specified, apply it to every sound under this command.
// If an emote was (re)specified, apply it to every sound under this command.
- if (req.body.emote_code !== undefined) { try { db.setChannelSoundEmote(channelOwnerId, command, emoteCode); } catch { /* */ } }
+ if (req.body.emote_code !== undefined) { try { await db.setChannelSoundEmote(channelOwnerId, command, emoteCode); } catch { /* */ } }
// Live's media-proxy/asset-sync mirrors the file to OpenVibe.Media (from Live's copy of the row).
// Live's media-proxy/asset-sync mirrors the file to OpenVibe.Media (from Live's copy of the row).
try { ctx.effects.assetSync('syncSoon'); } catch { /* mirror is best-effort */ }
try { ctx.effects.assetSync('syncSoon'); } catch { /* mirror is best-effort */ }
// Tell everyone watching this channel to refresh their sound list live.
// Tell everyone watching this channel to refresh their sound list live.
- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
+ try { await require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
res.json({
res.json({
sound: {
sound: {
@@ -314,20 +314,20 @@ router.use((err, req, res, next) => {
@@ -314,20 +314,20 @@ router.use((err, req, res, next) => {
// ── Delete a sound ───────────────────────────────────────────
// ── Delete a sound ───────────────────────────────────────────
router.delete('/:id', requireAuth, editLimit, async (req, res) => {
router.delete('/:id', requireAuth, editLimit, async (req, res) => {
try {
try {
- const sound = db.getChannelSoundById(parseInt(req.params.id));
+ const sound = await db.getChannelSoundById(parseInt(req.params.id));
if (!sound) return res.status(404).json({ error: 'Sound not found' });
if (!sound) return res.status(404).json({ error: 'Sound not found' });
let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');
let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');
if (!allowed) {
if (!allowed) {
const channel = await channelWithPolicy(sound.channel_owner_id);
const channel = await channelWithPolicy(sound.channel_owner_id);
- if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
+ if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
}
}
if (!allowed) return res.status(403).json({ error: 'Not your sound' });
if (!allowed) return res.status(403).json({ error: 'Not your sound' });
if (sound.url && fs.existsSync(sound.url)) { try { fs.unlinkSync(sound.url); } catch {} }
if (sound.url && fs.existsSync(sound.url)) { try { fs.unlinkSync(sound.url); } catch {} }
// The Media asset id lives on Live's copy of the row: Live removes the asset first.
// The Media asset id lives on Live's copy of the row: Live removes the asset first.
try { await ctx.effects.assetSync('remove-sound', sound.id); } catch { /* */ }
try { await ctx.effects.assetSync('remove-sound', sound.id); } catch { /* */ }
- db.deleteChannelSound(sound.id);
- try { require('./chat-server').broadcastToOwnerStreams(sound.channel_owner_id, { type: 'sounds-updated' }); } catch { /* */ }
+ await db.deleteChannelSound(sound.id);
+ try { await require('./chat-server').broadcastToOwnerStreams(sound.channel_owner_id, { type: 'sounds-updated' }); } catch { /* */ }
res.json({ message: 'Sound deleted' });
res.json({ message: 'Sound deleted' });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to delete sound' });
res.status(500).json({ error: 'Failed to delete sound' });
@@ -350,10 +350,10 @@ router.patch('/command', requireAuth, editLimit, async (req, res) => {
@@ -350,10 +350,10 @@ router.patch('/command', requireAuth, editLimit, async (req, res) => {
if (!channel) return res.status(404).json({ error: 'Channel not found' });
if (!channel) return res.status(404).json({ error: 'Channel not found' });
const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');
const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');
- const group = (db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);
+ const group = (await db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);
if (!group.length) return res.status(404).json({ error: `No sound command !${command} in this channel.` });
if (!group.length) return res.status(404).json({ error: `No sound command !${command} in this channel.` });
- const isMod = permissions.canModerateChannel(req.user, channel.id);
+ const isMod = await permissions.canModerateChannel(req.user, channel.id);
const isOwnChannel = channelOwnerId === req.user.id;
const isOwnChannel = channelOwnerId === req.user.id;
const isCreator = group.every((s) => s.created_by === req.user.id);
const isCreator = group.every((s) => s.created_by === req.user.id);
if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {
if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {
@@ -369,11 +369,11 @@ router.patch('/command', requireAuth, editLimit, async (req, res) => {
@@ -369,11 +369,11 @@ router.patch('/command', requireAuth, editLimit, async (req, res) => {
if (RESERVED_COMMANDS.has(next)) {
if (RESERVED_COMMANDS.has(next)) {
return res.status(400).json({ error: `"!${next}" is a reserved command — pick another name` });
return res.status(400).json({ error: `"!${next}" is a reserved command — pick another name` });
}
}
- if (next !== command && db.getChannelSoundByCommand(channelOwnerId, next)) {
+ if (next !== command && await db.getChannelSoundByCommand(channelOwnerId, next)) {
return res.status(409).json({ error: `!${next} already exists in this channel.` });
return res.status(409).json({ error: `!${next} already exists in this channel.` });
}
}
if (next !== command) {
if (next !== command) {
- db.renameChannelSoundCommand(channelOwnerId, command, next);
+ await db.renameChannelSoundCommand(channelOwnerId, command, next);
finalCommand = next;
finalCommand = next;
}
}
}
}
@@ -382,10 +382,10 @@ router.patch('/command', requireAuth, editLimit, async (req, res) => {
@@ -382,10 +382,10 @@ router.patch('/command', requireAuth, editLimit, async (req, res) => {
if (emoteCode && !/^[a-zA-Z0-9_]{2,32}$/.test(emoteCode)) {
if (emoteCode && !/^[a-zA-Z0-9_]{2,32}$/.test(emoteCode)) {
return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
}
}
- db.setChannelSoundEmote(channelOwnerId, finalCommand, emoteCode);
+ await db.setChannelSoundEmote(channelOwnerId, finalCommand, emoteCode);
}
}
- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
+ try { await require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
res.json({ message: 'Sound command updated', command: finalCommand });
res.json({ message: 'Sound command updated', command: finalCommand });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to update sound command' });
res.status(500).json({ error: 'Failed to update sound command' });
@@ -421,7 +421,7 @@ function _alertKind(raw) { return raw === 'goal' ? 'goal' : 'donation'; }
@@ -421,7 +421,7 @@ function _alertKind(raw) { return raw === 'goal' ? 'goal' : 'donation'; }
// GET which alert sounds are configured (+ a preview URL).
// GET which alert sounds are configured (+ a preview URL).
router.get('/alert/mine', requireAuth, async (req, res) => {
router.get('/alert/mine', requireAuth, async (req, res) => {
await channelWithPolicy(req.user.id);
await channelWithPolicy(req.user.id);
- const s = ctx.getChannelAlertSoundsByUser(req.user.id) || {};
+ const s = await ctx.getChannelAlertSoundsByUser(req.user.id) || {};
const toUrl = (p) => (p ? `/api/sounds/file/${path.basename(p)}` : null);
const toUrl = (p) => (p ? `/api/sounds/file/${path.basename(p)}` : null);
res.json({
res.json({
donation: { set: !!s.donation_sound_url, url: toUrl(s.donation_sound_url) },
donation: { set: !!s.donation_sound_url, url: toUrl(s.donation_sound_url) },
@@ -451,7 +451,7 @@ router.post('/alert/:kind', requireAuth, uploadLimit, soundUpload.single('sound'
@@ -451,7 +451,7 @@ router.post('/alert/:kind', requireAuth, uploadLimit, soundUpload.single('sound'
// Remove the previous alert sound of this kind (best-effort).
// Remove the previous alert sound of this kind (best-effort).
try {
try {
- const prev = ctx.getChannelAlertSoundsByUser(req.user.id) || {};
+ const prev = await ctx.getChannelAlertSoundsByUser(req.user.id) || {};
const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;
const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;
if (prevPath && fs.existsSync(prevPath) && path.resolve(prevPath) !== path.resolve(finalPath)) fs.unlink(prevPath, () => {});
if (prevPath && fs.existsSync(prevPath) && path.resolve(prevPath) !== path.resolve(finalPath)) fs.unlink(prevPath, () => {});
} catch { /* ignore */ }
} catch { /* ignore */ }
@@ -474,7 +474,7 @@ router.delete('/alert/:kind', requireAuth, editLimit, async (req, res) => {
@@ -474,7 +474,7 @@ router.delete('/alert/:kind', requireAuth, editLimit, async (req, res) => {
try {
try {
const channel = await channelWithPolicy(req.user.id);
const channel = await channelWithPolicy(req.user.id);
if (channel) {
if (channel) {
- const prev = ctx.getChannelAlertSoundsByUser(req.user.id) || {};
+ const prev = await ctx.getChannelAlertSoundsByUser(req.user.id) || {};
const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;
const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;
if (prevPath && fs.existsSync(prevPath)) { try { fs.unlinkSync(prevPath); } catch { /* */ } }
if (prevPath && fs.existsSync(prevPath)) { try { fs.unlinkSync(prevPath); } catch { /* */ } }
await ctx.effects.setChannelAlertSound(channel.id, kind, null, null, req.user.id);
await ctx.effects.setChannelAlertSound(channel.id, kind, null, null, req.user.id);
@@ -15,8 +15,63 @@ const db = require('../db/database');
@@ -15,8 +15,63 @@ const db = require('../db/database');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
-const cols = (d, t) => { try { return d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name); } catch { return []; } };
-const has = (d, t, c) => cols(d, t).includes(c);
+// Static statements per table (plan T3 decision 7: no interpolated identifiers). @to/@from: a subject or a Live id.
+const BY_SUBJECT_MESSAGES = [
+ 'UPDATE chat_messages SET subject_id = @to WHERE subject_id = @from',
+ 'UPDATE room_messages SET subject_id = @to WHERE subject_id = @from',
+ 'UPDATE dm_messages SET sender_subject_id = @to WHERE sender_subject_id = @from',
+];
+const BY_USER = [
+ 'UPDATE chat_messages SET user_id = @to WHERE user_id = @from',
+ 'UPDATE chat_messages SET channel_user_id = @to WHERE channel_user_id = @from',
+ 'UPDATE room_messages SET user_id = @to WHERE user_id = @from',
+ 'UPDATE dm_messages SET sender_id = @to WHERE sender_id = @from',
+ 'UPDATE dm_conversations SET created_by = @to WHERE created_by = @from',
+];
+// Membership: (group, user) is one row; the survivor's stays.
+const MEMBERSHIP = [
+ {
+ counter: 'dm',
+ groups: 'SELECT conversation_id AS g FROM dm_participants WHERE user_id = ?',
+ has: 'SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?',
+ drop: 'DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?',
+ move: 'UPDATE dm_participants SET user_id = ? WHERE conversation_id = ? AND user_id = ?',
+ subject: 'UPDATE dm_participants SET subject_id = @to WHERE subject_id = @from',
+ },
+ {
+ counter: 'rooms',
+ groups: 'SELECT room_id AS g FROM room_members WHERE user_id = ?',
+ has: 'SELECT 1 FROM room_members WHERE room_id = ? AND user_id = ?',
+ drop: 'DELETE FROM room_members WHERE room_id = ? AND user_id = ?',
+ move: 'UPDATE room_members SET user_id = ? WHERE room_id = ? AND user_id = ?',
+ subject: null,
+ },
+];
+const BY_SUBJECT_OTHER = [
+ 'UPDATE rooms SET owner_subject = @to WHERE owner_subject = @from',
+ 'UPDATE room_attachments SET attached_by_subject = @to WHERE attached_by_subject = @from',
+ 'UPDATE calls SET created_by_subject = @to WHERE created_by_subject = @from',
+ 'UPDATE calls SET target_subject = @to WHERE target_subject = @from',
+];
+// Blocks, from either side: the survivor's pair wins, blocking oneself goes.
+const DM_BLOCKS = [
+ { rows: 'SELECT id, blocked_id AS o FROM dm_blocks WHERE blocker_id = ?', exists: 'SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?', move: 'UPDATE dm_blocks SET blocker_id = ? WHERE id = ?' },
+ { rows: 'SELECT id, blocker_id AS o FROM dm_blocks WHERE blocked_id = ?', exists: 'SELECT 1 FROM dm_blocks WHERE blocked_id = ? AND blocker_id = ?', move: 'UPDATE dm_blocks SET blocked_id = ? WHERE id = ?' },
+];
+const NETWORK_BLOCKS = [
+ {
+ rows: 'SELECT blocked_subject AS o FROM network_blocks WHERE blocker_subject = ?',
+ exists: 'SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ?',
+ drop: 'DELETE FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ?',
+ move: 'UPDATE network_blocks SET blocker_subject = ? WHERE blocker_subject = ? AND blocked_subject = ?',
+ },
+ {
+ rows: 'SELECT blocker_subject AS o FROM network_blocks WHERE blocked_subject = ?',
+ exists: 'SELECT 1 FROM network_blocks WHERE blocked_subject = ? AND blocker_subject = ?',
+ drop: 'DELETE FROM network_blocks WHERE blocked_subject = ? AND blocker_subject = ?',
+ move: 'UPDATE network_blocks SET blocked_subject = ? WHERE blocked_subject = ? AND blocker_subject = ?',
+ },
+];
/** The payload { merge_id, from, into }, or null. */
/** The payload { merge_id, from, into }, or null. */
function payloadOf(event) {
function payloadOf(event) {
@@ -25,53 +80,44 @@ function payloadOf(event) {
@@ -25,53 +80,44 @@ function payloadOf(event) {
return { merge_id: p.merge_id, from: p.from, into: p.into };
return { merge_id: p.merge_id, from: p.from, into: p.into };
}
}
-function apply({ from, into, merge_id: mergeId }, { log = console } = {}) {
+async function apply({ from, into, merge_id: mergeId }, { log = console } = {}) {
const d = db.getDb();
const d = db.getDb();
- const idOf = (s) => { const r = d.prepare('SELECT id FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1').get(s); return r ? r.id : null; };
- const a = idOf(from); const b = idOf(into);
+ const idOf = async (s) => { const r = await d.prepare('SELECT id FROM ctx_users WHERE subject_id = ? ORDER BY id LIMIT 1').get(s); return r ? r.id : null; };
+ const a = await idOf(from); const b = await idOf(into);
const both = a != null && b != null && a !== b;
const both = a != null && b != null && a !== b;
const c = { messages: 0, dm: 0, rooms: 0, dropped: 0 };
const c = { messages: 0, dm: 0, rooms: 0, dropped: 0 };
- d.transaction(() => {
- const set = (t, col, to, fromVal) => (has(d, t, col) ? d.prepare(`UPDATE ${t} SET ${col} = ? WHERE ${col} = ?`).run(to, fromVal).changes : 0);
+ await d.tx(async () => {
+ const set = async (text, to, fromVal) => (await d.prepare(text).run({ to, from: fromVal })).changes;
// Messages: by subject, and by Live user id when both accounts have one.
// Messages: by subject, and by Live user id when both accounts have one.
- c.messages += set('chat_messages', 'subject_id', into, from) + set('room_messages', 'subject_id', into, from) + set('dm_messages', 'sender_subject_id', into, from);
- if (both) {
- set('chat_messages', 'user_id', b, a); set('chat_messages', 'channel_user_id', b, a);
- set('room_messages', 'user_id', b, a); set('dm_messages', 'sender_id', b, a);
- set('dm_conversations', 'created_by', b, a);
- }
- // Membership: (conversation, user) and (room, user) are one row each; the survivor's stays.
- for (const [t, group] of [['dm_participants', 'conversation_id'], ['room_members', 'room_id']]) {
- if (!has(d, t, 'user_id')) continue;
+ for (const q of BY_SUBJECT_MESSAGES) c.messages += await set(q, into, from);
+ if (both) for (const q of BY_USER) await set(q, b, a);
+ for (const m of MEMBERSHIP) {
if (both) {
if (both) {
- for (const r of d.prepare(`SELECT ${group} AS g FROM ${t} WHERE user_id = ?`).all(a)) {
- if (d.prepare(`SELECT 1 FROM ${t} WHERE ${group} = ? AND user_id = ?`).get(r.g, b)) { d.prepare(`DELETE FROM ${t} WHERE ${group} = ? AND user_id = ?`).run(r.g, a); c.dropped++; }
- else { d.prepare(`UPDATE ${t} SET user_id = ? WHERE ${group} = ? AND user_id = ?`).run(b, r.g, a); c[t === 'dm_participants' ? 'dm' : 'rooms']++; }
+ for (const r of await d.prepare(m.groups).all(a)) {
+ if (await d.prepare(m.has).get(r.g, b)) { await d.prepare(m.drop).run(r.g, a); c.dropped++; }
+ else { await d.prepare(m.move).run(b, r.g, a); c[m.counter]++; }
}
}
}
}
- if (has(d, t, 'subject_id')) set(t, 'subject_id', into, from);
+ if (m.subject) await set(m.subject, into, from);
}
}
- set('rooms', 'owner_subject', into, from); set('room_attachments', 'attached_by_subject', into, from);
- set('calls', 'created_by_subject', into, from); set('calls', 'target_subject', into, from);
+ for (const q of BY_SUBJECT_OTHER) await set(q, into, from);
// DM blocks (by Live user id, unique per pair) and the platform-block projection (by subject).
// DM blocks (by Live user id, unique per pair) and the platform-block projection (by subject).
- if (both && has(d, 'dm_blocks', 'blocker_id')) {
- for (const [col, other] of [['blocker_id', 'blocked_id'], ['blocked_id', 'blocker_id']]) {
- for (const r of d.prepare(`SELECT id, ${other} AS o FROM dm_blocks WHERE ${col} = ?`).all(a)) {
- if (r.o === b || d.prepare(`SELECT 1 FROM dm_blocks WHERE ${col} = ? AND ${other} = ?`).get(b, r.o)) { d.prepare('DELETE FROM dm_blocks WHERE id = ?').run(r.id); c.dropped++; }
- else d.prepare(`UPDATE dm_blocks SET ${col} = ? WHERE id = ?`).run(b, r.id);
+ if (both) {
+ for (const q of DM_BLOCKS) {
+ for (const r of await d.prepare(q.rows).all(a)) {
+ if (r.o === b || await d.prepare(q.exists).get(b, r.o)) { await d.prepare('DELETE FROM dm_blocks WHERE id = ?').run(r.id); c.dropped++; }
+ else await d.prepare(q.move).run(b, r.id);
}
}
}
}
}
}
- set('dm_blocks', 'blocker_subject_id', into, from);
- if (has(d, 'network_blocks', 'blocker_subject')) {
- for (const [col, other] of [['blocker_subject', 'blocked_subject'], ['blocked_subject', 'blocker_subject']]) {
- for (const r of d.prepare(`SELECT ${other} AS o FROM network_blocks WHERE ${col} = ?`).all(from)) {
- if (r.o === into || d.prepare(`SELECT 1 FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, r.o)) d.prepare(`DELETE FROM network_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, r.o);
- else d.prepare(`UPDATE network_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, r.o);
- }
+ await set('UPDATE dm_blocks SET blocker_subject_id = @to WHERE blocker_subject_id = @from', into, from);
+ for (const q of NETWORK_BLOCKS) {
+ for (const r of await d.prepare(q.rows).all(from)) {
+ if (r.o === into || await d.prepare(q.exists).get(into, r.o)) await d.prepare(q.drop).run(from, r.o);
+ else await d.prepare(q.move).run(into, from, r.o);
}
}
}
}
- })();
+ });
log.log(`[Merge] ${mergeId}: ${JSON.stringify({ live_users: [a, b], ...c })}`);
log.log(`[Merge] ${mergeId}: ${JSON.stringify({ live_users: [a, b], ...c })}`);
return 'merged';
return 'merged';
}
}
@@ -209,10 +209,10 @@ function autoUserVoiceParams(identityKey) {
@@ -209,10 +209,10 @@ function autoUserVoiceParams(identityKey) {
const gap = h[3] % 4; // 0..3 (subtle cadence variation)
const gap = h[3] % 4; // 0..3 (subtle cadence variation)
return { voice, pitch, speed, gap };
return { voice, pitch, speed, gap };
}
}
-function deriveUserVoiceParams(identityKey) {
+async function deriveUserVoiceParams(identityKey) {
const key = String(identityKey || 'anon').trim().toLowerCase() || 'anon';
const key = String(identityKey || 'anon').trim().toLowerCase() || 'anon';
// An admin-set override wins over the auto-assigned voice.
// An admin-set override wins over the auto-assigned voice.
- try { const ov = db.getTtsVoiceOverride(key); if (ov && ov.voice) return _clampVoiceParams(ov); } catch { /* fall through to auto */ }
+ try { const ov = await db.getTtsVoiceOverride(key); if (ov && ov.voice) return _clampVoiceParams(ov); } catch { /* fall through to auto */ }
return autoUserVoiceParams(key);
return autoUserVoiceParams(key);
}
}
@@ -227,10 +227,10 @@ async function synthesizeUserVoice(text, identityKey, username, maxLengthOverrid
@@ -227,10 +227,10 @@ async function synthesizeUserVoice(text, identityKey, username, maxLengthOverrid
const effMax = Math.min(1200, Math.max(1, Number(maxLengthOverride) || settings.maxLength));
const effMax = Math.min(1200, Math.max(1, Number(maxLengthOverride) || settings.maxLength));
const cleanText = sanitize(text, effMax, username);
const cleanText = sanitize(text, effMax, username);
if (!cleanText) return null;
if (!cleanText) return null;
- const params = deriveUserVoiceParams(identityKey);
+ const params = await deriveUserVoiceParams(identityKey);
const voiceDef = { engine: 'espeak-ng', params, name: `Voice-${params.voice}` };
const voiceDef = { engine: 'espeak-ng', params, name: `Voice-${params.voice}` };
// Use a synthetic voiceId so the client can distinguish auto voices if needed.
// Use a synthetic voiceId so the client can distinguish auto voices if needed.
- return _synthesizeWithDef(cleanText, voiceDef, `auto:${params.voice}`);
+ return await _synthesizeWithDef(cleanText, voiceDef, `auto:${params.voice}`);
}
}
/** Invalidate settings cache (call after admin updates) */
/** Invalidate settings cache (call after admin updates) */
@@ -542,10 +542,10 @@ async function synthesize(text, voiceId, username, maxLengthOverride) {
@@ -542,10 +542,10 @@ async function synthesize(text, voiceId, username, maxLengthOverride) {
if (!voiceDef) {
if (!voiceDef) {
// Fallback to default
// Fallback to default
const fallback = VOICE_CATALOG[settings.defaultVoice] || VOICE_CATALOG.gary;
const fallback = VOICE_CATALOG[settings.defaultVoice] || VOICE_CATALOG.gary;
- return _synthesizeWithDef(cleanText, fallback, settings.defaultVoice);
+ return await _synthesizeWithDef(cleanText, fallback, settings.defaultVoice);
}
}
- return _synthesizeWithDef(cleanText, voiceDef, vid);
+ return await _synthesizeWithDef(cleanText, voiceDef, vid);
}
}
async function _synthesizeWithDef(text, voiceDef, voiceId) {
async function _synthesizeWithDef(text, voiceDef, voiceId) {
@@ -646,7 +646,7 @@ function getAvailableVoices() {
@@ -646,7 +646,7 @@ function getAvailableVoices() {
async function synthesizeWithParams(text, params) {
async function synthesizeWithParams(text, params) {
const p = _clampVoiceParams(params || {});
const p = _clampVoiceParams(params || {});
const voiceDef = { engine: 'espeak-ng', params: p, name: `Voice-${p.voice}` };
const voiceDef = { engine: 'espeak-ng', params: p, name: `Voice-${p.voice}` };
- return _synthesizeWithDef(sanitize(text, getTTSSettings().maxLength, null), voiceDef, `preview:${p.voice}`);
+ return await _synthesizeWithDef(sanitize(text, getTTSSettings().maxLength, null), voiceDef, `preview:${p.voice}`);
}
}
module.exports = {
module.exports = {
@@ -192,22 +192,22 @@ async function queueRoom(req, res) {
@@ -192,22 +192,22 @@ async function queueRoom(req, res) {
if (streamId) {
if (streamId) {
const stream = await ctx.ensureStream(streamId).catch(() => null);
const stream = await ctx.ensureStream(streamId).catch(() => null);
if (!stream) { res.status(404).json({ error: 'No such stream' }); return null; }
if (!stream) { res.status(404).json({ error: 'No such stream' }); return null; }
- channel = stream.channel_id ? ctx.getChannelById(stream.channel_id) : ctx.getChannelByUserId(stream.user_id);
+ channel = stream.channel_id ? await ctx.getChannelById(stream.channel_id) : await ctx.getChannelByUserId(stream.user_id);
if (channel) await ctx.ensurePolicy(channel.id).catch(() => {}); // channel moderators
if (channel) await ctx.ensurePolicy(channel.id).catch(() => {}); // channel moderators
- allowed = permissions.canModerateStream(req.user, streamId);
+ allowed = await permissions.canModerateStream(req.user, streamId);
} else {
} else {
- channel = ctx.getChannelByUserId(channelUserId);
+ channel = await ctx.getChannelByUserId(channelUserId);
if (channel) await ctx.ensurePolicy(channel.id).catch(() => {});
if (channel) await ctx.ensurePolicy(channel.id).catch(() => {});
allowed = req.user.id === channelUserId || permissions.isGlobalModOrAbove(req.user)
allowed = req.user.id === channelUserId || permissions.isGlobalModOrAbove(req.user)
- || (!!channel && permissions.isChannelMod(req.user, channel.id));
+ || (!!channel && await permissions.isChannelMod(req.user, channel.id));
}
}
if (!allowed) { res.status(403).json({ error: 'Only the broadcaster and moderators can manage the TTS and sound queue' }); return null; }
if (!allowed) { res.status(403).json({ error: 'Only the broadcaster and moderators can manage the TTS and sound queue' }); return null; }
return { room: audioQueue.roomKey({ streamId, channelUserId }), streamId, channelUserId, channel };
return { room: audioQueue.roomKey({ streamId, channelUserId }), streamId, channelUserId, channel };
}
}
-function logQueueAction(req, r, action, details) {
+async function logQueueAction(req, r, action, details) {
try {
try {
- db.logModerationAction({
+ await db.logModerationAction({
scope_type: r.channel ? 'channel' : 'site',
scope_type: r.channel ? 'channel' : 'site',
scope_id: r.channel ? r.channel.id : undefined,
scope_id: r.channel ? r.channel.id : undefined,
actor_user_id: req.user.id,
actor_user_id: req.user.id,
@@ -221,7 +221,7 @@ router.get('/queue', requireAuth, async (req, res) => {
@@ -221,7 +221,7 @@ router.get('/queue', requireAuth, async (req, res) => {
try {
try {
const r = await queueRoom(req, res);
const r = await queueRoom(req, res);
if (!r) return;
if (!r) return;
- res.json(audioQueue.list(r.room, { recent: req.query.recent }));
+ res.json(await audioQueue.list(r.room, { recent: req.query.recent }));
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { res.status(500).json({ error: e.message }); }
});
});
@@ -234,10 +234,10 @@ router.post('/queue/skip', requireAuth, queueModerate, async (req, res) => {
@@ -234,10 +234,10 @@ router.post('/queue/skip', requireAuth, queueModerate, async (req, res) => {
const r = await queueRoom(req, res);
const r = await queueRoom(req, res);
if (!r) return;
if (!r) return;
const id = parseInt(req.body && req.body.id, 10) || null;
const id = parseInt(req.body && req.body.id, 10) || null;
- const skipped = audioQueue.skip(r.room, { id, actor: `user:${req.user.username}` });
+ const skipped = await audioQueue.skip(r.room, { id, actor: `user:${req.user.username}` });
if (!skipped) return res.status(id ? 404 : 409).json({ error: id ? 'No such request waiting or playing' : 'Nothing to skip' });
if (!skipped) return res.status(id ? 404 : 409).json({ error: id ? 'No such request waiting or playing' : 'Nothing to skip' });
- logQueueAction(req, r, 'tts_skip', { request_id: skipped.id, kind: skipped.kind });
- res.json({ skipped: { id: skipped.id, kind: skipped.kind, requested_by: skipped.requested_by }, queue: audioQueue.list(r.room) });
+ await logQueueAction(req, r, 'tts_skip', { request_id: skipped.id, kind: skipped.kind });
+ res.json({ skipped: { id: skipped.id, kind: skipped.kind, requested_by: skipped.requested_by }, queue: await audioQueue.list(r.room) });
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { res.status(500).json({ error: e.message }); }
});
});
@@ -245,9 +245,9 @@ router.post('/queue/clear', requireAuth, queueModerate, async (req, res) => {
@@ -245,9 +245,9 @@ router.post('/queue/clear', requireAuth, queueModerate, async (req, res) => {
try {
try {
const r = await queueRoom(req, res);
const r = await queueRoom(req, res);
if (!r) return;
if (!r) return;
- const ids = audioQueue.clear(r.room, { actor: `user:${req.user.username}` });
- if (ids.length) logQueueAction(req, r, 'tts_clear', { request_ids: ids });
- res.json({ cleared: ids, queue: audioQueue.list(r.room) });
+ const ids = await audioQueue.clear(r.room, { actor: `user:${req.user.username}` });
+ if (ids.length) await logQueueAction(req, r, 'tts_clear', { request_ids: ids });
+ res.json({ cleared: ids, queue: await audioQueue.list(r.room) });
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { res.status(500).json({ error: e.message }); }
});
});
@@ -257,9 +257,9 @@ router.post('/queue/:id/report', requireAuth, queueReport, async (req, res) => {
@@ -257,9 +257,9 @@ router.post('/queue/:id/report', requireAuth, queueReport, async (req, res) => {
if (!r) return;
if (!r) return;
const state = String((req.body && req.body.state) || '');
const state = String((req.body && req.body.state) || '');
if (state !== 'played' && state !== 'failed') return res.status(400).json({ error: "state must be 'played' or 'failed'" });
if (state !== 'played' && state !== 'failed') return res.status(400).json({ error: "state must be 'played' or 'failed'" });
- const ok = audioQueue.report(r.room, parseInt(req.params.id, 10), state, { error: req.body && req.body.error, actor: `user:${req.user.username}` });
+ const ok = await audioQueue.report(r.room, parseInt(req.params.id, 10), state, { error: req.body && req.body.error, actor: `user:${req.user.username}` });
if (!ok) return res.status(409).json({ error: 'That request is not playing' });
if (!ok) return res.status(409).json({ error: 'That request is not playing' });
- res.json({ ok: true, queue: audioQueue.list(r.room) });
+ res.json({ ok: true, queue: await audioQueue.list(r.room) });
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { res.status(500).json({ error: e.message }); }
});
});
@@ -38,15 +38,13 @@ module.exports = {
@@ -38,15 +38,13 @@ module.exports = {
hour: Math.max(1, int(process.env.CHAT_LIMITS_HOUR, 3000)),
hour: Math.max(1, int(process.env.CHAT_LIMITS_HOUR, 3000)),
},
},
- // Chat's own SQLite database (systemd StateDirectory openvibe-chat). The move to PostgreSQL
- // (plan T3) adds db below; this path stays for the transition and for the import source.
- dbPath: process.env.CHAT_DB_PATH || './data/chat.db',
-
// PostgreSQL (ADR-035): DATABASE_URL serves (through PgBouncer), DATABASE_DIRECT_URL runs migrations
// PostgreSQL (ADR-035): DATABASE_URL serves (through PgBouncer), DATABASE_DIRECT_URL runs migrations
- // (the owner role, a direct connection). Development and `npm test` use an embedded PGlite instead.
+ // (the owner role, a direct connection). Development and `npm test` use an embedded PGlite instead
+ // (server/db/database.js openDb). DATABASE_POOL_MAX caps this process's pool (default 10).
db: {
db: {
url: process.env.DATABASE_URL || '',
url: process.env.DATABASE_URL || '',
directUrl: process.env.DATABASE_DIRECT_URL || '',
directUrl: process.env.DATABASE_DIRECT_URL || '',
+ max: Math.max(1, int(process.env.DATABASE_POOL_MAX, 10)),
},
},
// Valkey: the shared per-actor rate-limit counters (openvibe-sdk/limits). Unset = in-process counters.
// Valkey: the shared per-actor rate-limit counters (openvibe-sdk/limits). Unset = in-process counters.
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/**
/**
- * OpenVibe.Chat — database access (better-sqlite3).
+ * OpenVibe.Chat — database access (PostgreSQL through openvibe-sdk/db; every helper is async).
*
*
* The chat functions below are OpenVibe.Live's (server/db/database.js) moved as they were:
* The chat functions below are OpenVibe.Live's (server/db/database.js) moved as they were:
* same names, same arguments, same SQL, so Live's forwarded calls (server/bridge/) and the moved
* same names, same arguments, same SQL, so Live's forwarded calls (server/bridge/) and the moved
@@ -12,13 +12,16 @@
@@ -12,13 +12,16 @@
*/
*/
'use strict';
'use strict';
-const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const fs = require('fs');
+const { createDb } = require('openvibe-sdk/db');
const config = require('../config');
const config = require('../config');
+const MIGRATIONS = path.join(__dirname, '..', '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', '..', 'data', 'pglite');
+
let db = null;
let db = null;
-let _dbPath = null;
+let _idTables = null; // tables with an `id` column: an INSERT into one returns it (lastInsertRowid)
const _stmts = new Map();
const _stmts = new Map();
// Tables Chat writes from the cutover on; Live keeps a read mirror of each (server/bridge/live-mirror.js).
// Tables Chat writes from the cutover on; Live keeps a read mirror of each (server/bridge/live-mirror.js).
@@ -30,116 +33,107 @@ const CHAT_TABLES = {
@@ -30,116 +33,107 @@ const CHAT_TABLES = {
dm_blocks: ['id'],
dm_blocks: ['id'],
tts_voice_overrides: ['identity_key'],
tts_voice_overrides: ['identity_key'],
channel_sounds: ['id'],
channel_sounds: ['id'],
- relay_users: ['platform', 'username'],
+ relay_users: ['id'],
hidden_relay_users: ['id'],
hidden_relay_users: ['id'],
pending_ip_messages: ['id'],
pending_ip_messages: ['id'],
stream_first_chats: ['chatter_key', 'channel_user_id'],
stream_first_chats: ['chatter_key', 'channel_user_id'],
moderation_actions: ['id'],
moderation_actions: ['id'],
};
};
-function getDb() {
- if (!db) {
- _dbPath = path.resolve(config.dbPath);
- fs.mkdirSync(path.dirname(_dbPath), { recursive: true });
- db = new Database(_dbPath);
- db.pragma('journal_mode = WAL');
- db.pragma('foreign_keys = ON');
- db.pragma('busy_timeout = 5000');
- try {
- db.pragma('synchronous = NORMAL');
- db.pragma('cache_size = -65536');
- db.pragma('temp_store = MEMORY');
- } catch (e) { console.warn('[DB] pragma tuning:', e.message); }
+
+/**
+ * The serving handle (ADR-035): DATABASE_URL through PgBouncer (the runtime role, DML only); migrations run
+ * first as the owner on DATABASE_DIRECT_URL. Outside production without DATABASE_URL: an embedded PGlite
+ * database in data/pglite (CHAT_PGLITE_DIR overrides it; one process). Timestamps stay SQLite-format text
+ * (ov_now(), datetime() in migrations/0001_initial.sql).
+ */
+async function openDb(cfg = config, { log = console } = {}) {
+ if (!cfg.db.url) {
+ if (cfg.nodeEnv === 'production') throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh chat)');
+ const dir = process.env.CHAT_PGLITE_DIR || DEV_PGLITE;
+ if (cfg.nodeEnv !== 'test') log.warn(`[DB] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const d = createDb({ pglite: dir, service: 'chat', log });
+ await d.migrate({ dir: MIGRATIONS, log: quiet(log) });
+ return d;
}
}
+ if (!cfg.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: cfg.db.directUrl, service: 'chat-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log: quiet(log) }); } finally { await owner.close(); }
+ return createDb({ url: cfg.db.url, service: 'chat', max: cfg.db.max, log });
+}
+function quiet(log) { return { log() {}, info() {}, warn: (...a) => log.warn(...a), error: (...a) => log.error(...a) }; }
+
+/** Open the process-wide database once, at boot (server/index.js, scripts). */
+async function initDb(cfg = config, opts) {
+ if (!db && globalThis.__ovChatTestDb) db = globalThis.__ovChatTestDb; // tests (test/helpers/pg-preload.mjs)
+ if (!db) db = await openDb(cfg, opts);
+ if (!_idTables) {
+ const rows = await db.many(`SELECT table_name FROM information_schema.columns
+ WHERE table_schema = current_schema() AND column_name = 'id'`);
+ _idTables = new Set(rows.map((r) => r.table_name));
+ }
+ return db;
+}
+
+/** The process-wide database (openvibe-sdk/db) initDb() opened. */
+function getDb() {
+ if (!db && globalThis.__ovChatTestDb) db = globalThis.__ovChatTestDb;
+ if (!db) throw new Error('the database is not open: await initDb() at boot');
return db;
return db;
}
}
+/** 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).
+const INSERT_INTO = /^\s*INSERT\s+INTO\s+([a-z_]+)/i;
+function withReturning(sql) {
+ if (/\bRETURNING\b/i.test(sql)) return sql;
+ const m = INSERT_INTO.exec(sql);
+ return m && _idTables && _idTables.has(m[1].toLowerCase()) ? `${sql} RETURNING id` : sql;
+}
+
function stmt(sql) {
function stmt(sql) {
let s = _stmts.get(sql);
let s = _stmts.get(sql);
if (!s) {
if (!s) {
- s = getDb().prepare(sql);
+ s = getDb().prepare(withReturning(sql));
if (_stmts.size > 500) _stmts.clear();
if (_stmts.size > 500) _stmts.clear();
_stmts.set(sql, s);
_stmts.set(sql, s);
}
}
return s;
return s;
}
}
-function run(sql, params = []) {
+async function run(sql, params = []) {
return stmt(sql).run(...(Array.isArray(params) ? params : [params]));
return stmt(sql).run(...(Array.isArray(params) ? params : [params]));
}
}
-function get(sql, params = []) {
+async function get(sql, params = []) {
return stmt(sql).get(...(Array.isArray(params) ? params : [params]));
return stmt(sql).get(...(Array.isArray(params) ? params : [params]));
}
}
-function all(sql, params = []) {
+async function all(sql, params = []) {
return stmt(sql).all(...(Array.isArray(params) ? params : [params]));
return stmt(sql).all(...(Array.isArray(params) ? params : [params]));
}
}
-function transaction(fn) {
- return getDb().transaction(fn)();
+/** fn runs in one transaction; db calls inside it join it (openvibe-sdk/db ambient transactions). */
+async function transaction(fn) {
+ return await getDb().tx(async () => await fn());
}
}
-function close() {
- if (db) { try { db.close(); } catch { /* */ } }
+async function close() {
+ const d = db;
db = null;
db = null;
_stmts.clear();
_stmts.clear();
-}
-
-/**
- * Create the schema. opts.captureMirror installs this connection's TEMP triggers that record
- * every change to Chat's tables for the Live mirror — the service does; the importer does not,
- * so imported rows (which came from Live) are never sent back.
- */
-const ADDED_COLUMNS = [
- ['emotes', 'media_url', 'TEXT'], // Live: emote images synced to OpenVibe.Media
- ['emotes', 'media_asset_id', 'INTEGER'],
- ['ctx_users', 'subject_id', 'TEXT'], // older Chat databases; auth/network-session.js looks users up by it
- ['channel_moderation_settings', 'sub_only', 'INTEGER DEFAULT 0'], // sub-only chat (WS-I task 6), Live has it too
- ['ctx_channels', 'emote_sources', 'TEXT'], // the channel's emote source switches (T3, Live's channels row)
-];
-
-function initDb({ captureMirror = false } = {}) {
- const d = getDb();
- d.exec(fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'));
- // Columns Live added after a Chat database was created (CREATE TABLE IF NOT EXISTS does not add them).
- for (const [table, column, type] of ADDED_COLUMNS) {
- const have = d.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
- if (!have) d.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
- }
- // auth/network-session.js resolves a verified Network token to its user by subject.
- d.exec('CREATE INDEX IF NOT EXISTS idx_ctx_users_subject ON ctx_users(subject_id)');
- // C-04 is done: Chat is the only writer of the six tables, so there is no authority left to
- // record. Drop the table from databases an earlier release created (it stayed one release for N-1);
- // a new database never gets it (schema.sql no longer has it).
- d.exec('DROP TABLE IF EXISTS table_authority');
- if (captureMirror) installMirrorTriggers();
- return d;
-}
-
-function installMirrorTriggers() {
- const d = getDb();
- const tables = [
- ...Object.entries(CHAT_TABLES).map(([table, pk]) => [table, pk, '']),
- ];
- for (const [table, pk, when] of tables) {
- const obj = (alias) => `json_object(${pk.map((c) => `'${c}', ${alias}.${c}`).join(', ')})`;
- d.exec(`
- CREATE TEMP TRIGGER IF NOT EXISTS mirror_${table}_ins AFTER INSERT ON main.${table} ${when}
- BEGIN INSERT INTO live_mirror_outbox (tbl, op, pk) VALUES ('${table}', 'upsert', ${obj('NEW')}); END;
- CREATE TEMP TRIGGER IF NOT EXISTS mirror_${table}_upd AFTER UPDATE ON main.${table} ${when}
- BEGIN INSERT INTO live_mirror_outbox (tbl, op, pk) VALUES ('${table}', 'upsert', ${obj('NEW')}); END;
- CREATE TEMP TRIGGER IF NOT EXISTS mirror_${table}_del AFTER DELETE ON main.${table} ${when}
- BEGIN INSERT INTO live_mirror_outbox (tbl, op, pk) VALUES ('${table}', 'delete', ${obj('OLD')}); END;
- `);
- }
+ if (d && d !== globalThis.__ovChatTestDb) { try { await d.close(); } catch { /* */ } }
}
}
// ── Subjects ──────────────────────────────────────────────────
// ── Subjects ──────────────────────────────────────────────────
// Live user id → Network subject (usr_…) from the ctx_users projection. New rows carry it so a
// Live user id → Network subject (usr_…) from the ctx_users projection. New rows carry it so a
// later wave can drop Live ids.
// later wave can drop Live ids.
-function subjectFor(userId) {
+async function subjectFor(userId) {
if (!userId) return null;
if (!userId) return null;
- try { return get('SELECT subject_id FROM ctx_users WHERE id = ?', [userId])?.subject_id || null; } catch { return null; }
+ try { return (await get('SELECT subject_id FROM ctx_users WHERE id = ?', [userId]))?.subject_id || null; } catch { return null; }
}
}
function _outbox() { return require('../events/outbox'); }
function _outbox() { return require('../events/outbox'); }
@@ -154,11 +148,11 @@ const DELETED_EVENT_IDS = 500;
@@ -154,11 +148,11 @@ const DELETED_EVENT_IDS = 500;
* author or who deleted it). payload.redacts asks OpenVibe.Events to turn the stored
* author or who deleted it). payload.redacts asks OpenVibe.Events to turn the stored
* chat.message.created of each id into a tombstone, so the text stops being replayable there.
* chat.message.created of each id into a tombstone, so the text stops being replayable there.
*/
*/
-function _announceDeleted(ids) {
+async function _announceDeleted(ids) {
const list = [...new Set((ids || []).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
const list = [...new Set((ids || []).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
for (let i = 0; i < list.length; i += DELETED_EVENT_IDS) {
for (let i = 0; i < list.length; i += DELETED_EVENT_IDS) {
const part = list.slice(i, i + DELETED_EVENT_IDS);
const part = list.slice(i, i + DELETED_EVENT_IDS);
- _outbox().enqueue({
+ await _outbox().enqueue({
event_type: 'chat.message.deleted',
event_type: 'chat.message.deleted',
visibility: 'public',
visibility: 'public',
subject: { type: 'chat_message', id: String(part[0]) },
subject: { type: 'chat_message', id: String(part[0]) },
@@ -172,21 +166,21 @@ function _announceDeleted(ids) {
@@ -172,21 +166,21 @@ function _announceDeleted(ids) {
// ── Chat messages ─────────────────────────────────────────────
// ── Chat messages ─────────────────────────────────────────────
-function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata }) {
+async function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata }) {
// channel_user_id = the broadcaster's user id — set for all channel/stream
// channel_user_id = the broadcaster's user id — set for all channel/stream
// messages so a streamer's chat history survives across sessions AND offline
// messages so a streamer's chat history survives across sessions AND offline
// periods (independent of the live-session stream row's lifetime).
// periods (independent of the live-session stream row's lifetime).
let chanUid = channel_user_id || null;
let chanUid = channel_user_id || null;
- if (!chanUid && stream_id) { try { chanUid = _ctx().getStreamById(stream_id)?.user_id || null; } catch { /* ignore */ } }
+ if (!chanUid && stream_id) { try { chanUid = (await _ctx().getStreamById(stream_id))?.user_id || null; } catch { /* ignore */ } }
const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
- const subject = subjectFor(user_id);
- return transaction(() => {
- const res = run(
+ const subject = await subjectFor(user_id);
+ return await transaction(async () => {
+ const res = await run(
`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id)
`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr, subject]
[stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr, subject]
);
);
- _outbox().enqueue({
+ await _outbox().enqueue({
event_type: 'chat.message.created',
event_type: 'chat.message.created',
visibility: 'public',
visibility: 'public',
actorSubject: subject,
actorSubject: subject,
@@ -208,16 +202,16 @@ function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, usernam
@@ -208,16 +202,16 @@ function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, usernam
});
});
}
}
-function searchChatMessages({ query, userId, anonId, username, streamId, limit = 50, offset = 0 }) {
+async function searchChatMessages({ query, userId, anonId, username, streamId, limit = 50, offset = 0 }) {
let sql = `SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color
let sql = `SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color
FROM chat_messages cm
FROM chat_messages cm
LEFT JOIN ctx_users u ON cm.user_id = u.id
LEFT JOIN ctx_users u ON cm.user_id = u.id
WHERE cm.is_deleted = 0
WHERE cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
+ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;
const params = [];
const params = [];
if (query) {
if (query) {
- sql += ` AND cm.message LIKE ?`;
+ sql += ` AND cm.message ILIKE ?`;
params.push(`%${query}%`);
params.push(`%${query}%`);
}
}
if (userId) {
if (userId) {
@@ -229,7 +223,7 @@ function searchChatMessages({ query, userId, anonId, username, streamId, limit =
@@ -229,7 +223,7 @@ function searchChatMessages({ query, userId, anonId, username, streamId, limit =
params.push(anonId);
params.push(anonId);
}
}
if (username) {
if (username) {
- sql += ` AND LOWER(u.username) LIKE ?`;
+ sql += ` AND LOWER(u.username) ILIKE ?`;
params.push(`%${username.toLowerCase()}%`);
params.push(`%${username.toLowerCase()}%`);
}
}
if (streamId) {
if (streamId) {
@@ -238,73 +232,73 @@ function searchChatMessages({ query, userId, anonId, username, streamId, limit =
@@ -238,73 +232,73 @@ function searchChatMessages({ query, userId, anonId, username, streamId, limit =
}
}
const countSql = sql.replace(/SELECT cm\.\*.*FROM/, 'SELECT COUNT(*) as c FROM');
const countSql = sql.replace(/SELECT cm\.\*.*FROM/, 'SELECT COUNT(*) as c FROM');
- const total = get(countSql, params)?.c || 0;
+ const total = (await get(countSql, params))?.c || 0;
sql += ` ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;
sql += ` ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
params.push(limit, offset);
- return { messages: all(sql, params), total };
+ return { messages: await all(sql, params), total };
}
}
-function getUserChatHistory(userId, limit = 50, offset = 0) {
+async function getUserChatHistory(userId, limit = 50, offset = 0) {
const sql = `SELECT cm.*, s.title as stream_title
const sql = `SELECT cm.*, s.title as stream_title
FROM chat_messages cm
FROM chat_messages cm
LEFT JOIN ctx_streams s ON cm.stream_id = s.id
LEFT JOIN ctx_streams s ON cm.stream_id = s.id
WHERE cm.user_id = ? AND cm.is_deleted = 0
WHERE cm.user_id = ? AND cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)
+ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())
ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;
ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;
- const messages = all(sql, [userId, limit, offset]);
- const total = get(
+ const messages = await all(sql, [userId, limit, offset]);
+ const total = (await get(
`SELECT COUNT(*) as c FROM chat_messages
`SELECT COUNT(*) as c FROM chat_messages
WHERE user_id = ? AND is_deleted = 0
WHERE user_id = ? AND is_deleted = 0
- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,
+ AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > ov_now())`,
[userId]
[userId]
- )?.c || 0;
+ ))?.c || 0;
return { messages, total };
return { messages, total };
}
}
-function getChatReplay(streamId, fromTime, toTime) {
+async function getChatReplay(streamId, fromTime, toTime) {
let sql = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name
let sql = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name
FROM chat_messages cm
FROM chat_messages cm
LEFT JOIN ctx_users u ON cm.user_id = u.id
LEFT JOIN ctx_users u ON cm.user_id = u.id
WHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat'
WHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat'
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
+ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;
const params = [streamId];
const params = [streamId];
if (fromTime) { sql += ` AND cm.timestamp >= ?`; params.push(fromTime); }
if (fromTime) { sql += ` AND cm.timestamp >= ?`; params.push(fromTime); }
if (toTime) { sql += ` AND cm.timestamp <= ?`; params.push(toTime); }
if (toTime) { sql += ` AND cm.timestamp <= ?`; params.push(toTime); }
sql += ` ORDER BY cm.timestamp ASC`;
sql += ` ORDER BY cm.timestamp ASC`;
- return all(sql, params);
+ return await all(sql, params);
}
}
/**
/**
* Get a single chat message by ID.
* Get a single chat message by ID.
*/
*/
-function getChatMessageById(id) {
- return get('SELECT * FROM chat_messages WHERE id = ?', [id]);
+async function getChatMessageById(id) {
+ return chatMessageRow(await get('SELECT * FROM chat_messages WHERE id = ?', [id]));
}
}
/** Shallow-merge a JSON patch into chat_messages.metadata (e.g. an async translation). */
/** Shallow-merge a JSON patch into chat_messages.metadata (e.g. an async translation). */
-function mergeChatMessageMetadata(id, patch) {
+async function mergeChatMessageMetadata(id, patch) {
if (!id || !patch || typeof patch !== 'object') return;
if (!id || !patch || typeof patch !== 'object') return;
- const row = get('SELECT metadata FROM chat_messages WHERE id = ?', [id]);
+ const row = await get('SELECT metadata FROM chat_messages WHERE id = ?', [id]);
if (!row) return;
if (!row) return;
let meta = {};
let meta = {};
try { meta = row.metadata ? JSON.parse(row.metadata) : {}; } catch { meta = {}; }
try { meta = row.metadata ? JSON.parse(row.metadata) : {}; } catch { meta = {}; }
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) meta = {};
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) meta = {};
- return run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...meta, ...patch }), id]);
+ return await run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...meta, ...patch }), id]);
}
}
/**
/**
* Soft-delete a chat message by ID. Sets is_deleted=1 and records who deleted it. Every delete below
* Soft-delete a chat message by ID. Sets is_deleted=1 and records who deleted it. Every delete below
* also announces chat.message.deleted in the same transaction (_announceDeleted).
* also announces chat.message.deleted in the same transaction (_announceDeleted).
*/
*/
-function deleteChatMessage(id, deletedBy = null) {
- return transaction(() => {
- const res = run(
- 'UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id = ?',
+async function deleteChatMessage(id, deletedBy = null) {
+ return await transaction(async () => {
+ const res = await run(
+ 'UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = ov_now() WHERE id = ?',
[deletedBy, id]
[deletedBy, id]
);
);
- if (res.changes) _announceDeleted([id]);
+ if (res.changes) await _announceDeleted([id]);
return res;
return res;
});
});
}
}
@@ -313,15 +307,15 @@ function deleteChatMessage(id, deletedBy = null) {
@@ -313,15 +307,15 @@ function deleteChatMessage(id, deletedBy = null) {
* Soft-delete ALL chat messages from a specific user, optionally scoped to a stream.
* Soft-delete ALL chat messages from a specific user, optionally scoped to a stream.
* Returns the list of deleted message IDs for real-time broadcast.
* Returns the list of deleted message IDs for real-time broadcast.
*/
*/
-function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } = {}) {
+async function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } = {}) {
const condition = streamId
const condition = streamId
? 'user_id = ? AND stream_id = ? AND is_deleted = 0'
? 'user_id = ? AND stream_id = ? AND is_deleted = 0'
: 'user_id = ? AND is_deleted = 0';
: 'user_id = ? AND is_deleted = 0';
const params = streamId ? [userId, streamId] : [userId];
const params = streamId ? [userId, streamId] : [userId];
- return transaction(() => {
- const ids = all(`SELECT id FROM chat_messages WHERE ${condition}`, params).map(m => m.id);
+ return await transaction(async () => {
+ const ids = (await all(`SELECT id FROM chat_messages WHERE ${condition} ORDER BY id`, params)).map(m => m.id);
if (ids.length === 0) return [];
if (ids.length === 0) return [];
- _softDeleteIds(ids, deletedBy);
+ await _softDeleteIds(ids, deletedBy);
return ids;
return ids;
});
});
}
}
@@ -329,15 +323,15 @@ function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } =
@@ -329,15 +323,15 @@ function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } =
/**
/**
* Soft-delete ALL chat messages from a specific anon_id, optionally scoped to stream.
* Soft-delete ALL chat messages from a specific anon_id, optionally scoped to stream.
*/
*/
-function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } = {}) {
+async function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } = {}) {
const condition = streamId
const condition = streamId
? 'anon_id = ? AND stream_id = ? AND is_deleted = 0'
? 'anon_id = ? AND stream_id = ? AND is_deleted = 0'
: 'anon_id = ? AND is_deleted = 0';
: 'anon_id = ? AND is_deleted = 0';
const params = streamId ? [anonId, streamId] : [anonId];
const params = streamId ? [anonId, streamId] : [anonId];
- return transaction(() => {
- const ids = all(`SELECT id FROM chat_messages WHERE ${condition}`, params).map(m => m.id);
+ return await transaction(async () => {
+ const ids = (await all(`SELECT id FROM chat_messages WHERE ${condition} ORDER BY id`, params)).map(m => m.id);
if (ids.length === 0) return [];
if (ids.length === 0) return [];
- _softDeleteIds(ids, deletedBy);
+ await _softDeleteIds(ids, deletedBy);
return ids;
return ids;
});
});
}
}
@@ -345,33 +339,33 @@ function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } =
@@ -345,33 +339,33 @@ function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } =
/**
/**
* Soft-delete ALL messages from a relayed external username (e.g. "[Twitch] foobar")
* Soft-delete ALL messages from a relayed external username (e.g. "[Twitch] foobar")
*/
*/
-function deleteRelayUserMessages(username, { streamId = null, deletedBy = null } = {}) {
+async function deleteRelayUserMessages(username, { streamId = null, deletedBy = null } = {}) {
const condition = streamId
const condition = streamId
? 'username = ? AND stream_id = ? AND is_deleted = 0'
? 'username = ? AND stream_id = ? AND is_deleted = 0'
: 'username = ? AND is_deleted = 0';
: 'username = ? AND is_deleted = 0';
const params = streamId ? [username, streamId] : [username];
const params = streamId ? [username, streamId] : [username];
- return transaction(() => {
- const ids = all(`SELECT id FROM chat_messages WHERE ${condition}`, params).map(m => m.id);
+ return await transaction(async () => {
+ const ids = (await all(`SELECT id FROM chat_messages WHERE ${condition} ORDER BY id`, params)).map(m => m.id);
if (ids.length === 0) return [];
if (ids.length === 0) return [];
- _softDeleteIds(ids, deletedBy);
+ await _softDeleteIds(ids, deletedBy);
return ids;
return ids;
});
});
}
}
/** Mark `ids` deleted (in chunks, under SQLite's variable limit) and announce them. In a transaction. */
/** Mark `ids` deleted (in chunks, under SQLite's variable limit) and announce them. In a transaction. */
-function _softDeleteIds(ids, deletedBy) {
+async function _softDeleteIds(ids, deletedBy) {
for (let i = 0; i < ids.length; i += DELETED_EVENT_IDS) {
for (let i = 0; i < ids.length; i += DELETED_EVENT_IDS) {
const part = ids.slice(i, i + DELETED_EVENT_IDS);
const part = ids.slice(i, i + DELETED_EVENT_IDS);
- run(
- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${part.map(() => '?').join(',')})`,
+ await run(
+ `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = ov_now() WHERE id IN (${part.map(() => '?').join(',')})`,
[deletedBy, ...part]
[deletedBy, ...part]
);
);
}
}
- _announceDeleted(ids);
+ await _announceDeleted(ids);
}
}
-function deleteExpiredChatMessages(limit = 500) {
- const rows = all(
+async function deleteExpiredChatMessages(limit = 500) {
+ const rows = await all(
`SELECT id, stream_id
`SELECT id, stream_id
FROM chat_messages
FROM chat_messages
WHERE is_deleted = 0
WHERE is_deleted = 0
@@ -379,8 +373,8 @@ function deleteExpiredChatMessages(limit = 500) {
@@ -379,8 +373,8 @@ function deleteExpiredChatMessages(limit = 500) {
-- Compared raw, not through datetime(): wrapping the column in a function makes the
-- Compared raw, not through datetime(): wrapping the column in a function makes the
-- index on auto_delete_at unusable, and this sweep runs every 30 seconds against the
-- index on auto_delete_at unusable, and this sweep runs every 30 seconds against the
-- biggest table on the site. Values are stored in the same 'YYYY-MM-DD HH:MM:SS' shape
-- biggest table on the site. Values are stored in the same 'YYYY-MM-DD HH:MM:SS' shape
- -- CURRENT_TIMESTAMP produces, so a string comparison sorts identically.
- AND auto_delete_at <= CURRENT_TIMESTAMP
+ -- ov_now() produces, so a string comparison sorts identically.
+ AND auto_delete_at <= ov_now()
ORDER BY auto_delete_at ASC
ORDER BY auto_delete_at ASC
LIMIT ?`,
LIMIT ?`,
[Math.max(1, Number(limit) || 500)]
[Math.max(1, Number(limit) || 500)]
@@ -389,14 +383,14 @@ function deleteExpiredChatMessages(limit = 500) {
@@ -389,14 +383,14 @@ function deleteExpiredChatMessages(limit = 500) {
const ids = rows.map(row => row.id);
const ids = rows.map(row => row.id);
const placeholders = ids.map(() => '?').join(',');
const placeholders = ids.map(() => '?').join(',');
- transaction(() => {
- run(
+ await transaction(async () => {
+ await run(
`UPDATE chat_messages
`UPDATE chat_messages
- SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP
+ SET is_deleted = 1, deleted_at = ov_now()
WHERE id IN (${placeholders})`,
WHERE id IN (${placeholders})`,
ids
ids
);
);
- _announceDeleted(ids);
+ await _announceDeleted(ids);
});
});
return rows;
return rows;
}
}
@@ -404,34 +398,34 @@ function deleteExpiredChatMessages(limit = 500) {
@@ -404,34 +398,34 @@ function deleteExpiredChatMessages(limit = 500) {
// Time ranges (purge, its preview, the log filter): the dashboard sends ISO instants ('…T…Z') and
// Time ranges (purge, its preview, the log filter): the dashboard sends ISO instants ('…T…Z') and
// rows keep SQLite's 'YYYY-MM-DD HH:MM:SS'. Compared as TEXT, 'T' sorts after ' ', so a range
// rows keep SQLite's 'YYYY-MM-DD HH:MM:SS'. Compared as TEXT, 'T' sorts after ' ', so a range
// matched nothing on its first day and all of its last; datetime(?) reads both forms as UTC.
// matched nothing on its first day and all of its last; datetime(?) reads both forms as UTC.
-function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
+async function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
// Global chat is is_global = 1. The ids are read first, in the same transaction, to announce them.
// Global chat is is_global = 1. The ids are read first, in the same transaction, to announce them.
const where = streamId
const where = streamId
? 'stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0'
? 'stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0'
: 'is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0';
: 'is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0';
const params = streamId ? [streamId, fromTime, toTime] : [fromTime, toTime];
const params = streamId ? [streamId, fromTime, toTime] : [fromTime, toTime];
- return transaction(() => {
- const ids = all(`SELECT id FROM chat_messages WHERE ${where}`, params).map(m => m.id);
- const res = run(
- `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE ${where}`,
+ return await transaction(async () => {
+ const ids = (await all(`SELECT id FROM chat_messages WHERE ${where} ORDER BY id`, params)).map(m => m.id);
+ const res = await run(
+ `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = ov_now() WHERE ${where}`,
[deletedBy, ...params]
[deletedBy, ...params]
);
);
- _announceDeleted(ids);
+ await _announceDeleted(ids);
// The ids too, so the purge reaches every surface that showed them (not only the stream's sockets).
// The ids too, so the purge reaches every surface that showed them (not only the stream's sockets).
return Object.assign(res, { ids });
return Object.assign(res, { ids });
});
});
}
}
-function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
+async function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
let row;
let row;
if (streamId) {
if (streamId) {
- row = get(
+ row = await get(
`SELECT COUNT(*) as cnt FROM chat_messages
`SELECT COUNT(*) as cnt FROM chat_messages
WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
[streamId, fromTime, toTime]
[streamId, fromTime, toTime]
);
);
} else {
} else {
- row = get(
+ row = await get(
`SELECT COUNT(*) as cnt FROM chat_messages
`SELECT COUNT(*) as cnt FROM chat_messages
WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0`,
[fromTime, toTime]
[fromTime, toTime]
@@ -440,13 +434,13 @@ function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
@@ -440,13 +434,13 @@ function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
return row?.cnt || 0;
return row?.cnt || 0;
}
}
-function getChatLogs({ streamId, username, search, from, to, messageType, page = 1, limit = 50, includeDeleted = false } = {}) {
+async function getChatLogs({ streamId, username, search, from, to, messageType, page = 1, limit = 50, includeDeleted = false } = {}) {
const conditions = [];
const conditions = [];
const params = [];
const params = [];
if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }
if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }
- if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }
- if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }
+ if (username) { conditions.push('username ILIKE ?'); params.push(`%${username}%`); }
+ if (search) { conditions.push('message ILIKE ?'); params.push(`%${search}%`); }
if (from) { conditions.push('timestamp >= datetime(?)'); params.push(from); }
if (from) { conditions.push('timestamp >= datetime(?)'); params.push(from); }
if (to) { conditions.push('timestamp <= datetime(?)'); params.push(to); }
if (to) { conditions.push('timestamp <= datetime(?)'); params.push(to); }
if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }
if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }
@@ -455,19 +449,19 @@ function getChatLogs({ streamId, username, search, from, to, messageType, page =
@@ -455,19 +449,19 @@ function getChatLogs({ streamId, username, search, from, to, messageType, page =
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const offset = (page - 1) * limit;
const offset = (page - 1) * limit;
- const countRow = get(`SELECT COUNT(*) as total FROM chat_messages ${where}`, params);
+ const countRow = await get(`SELECT COUNT(*) as total FROM chat_messages ${where}`, params);
const total = countRow?.total || 0;
const total = countRow?.total || 0;
- const rows = all(
+ const rows = (await all(
`SELECT * FROM chat_messages ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
`SELECT * FROM chat_messages ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
[...params, limit, offset]
[...params, limit, offset]
- );
+ )).map(chatMessageRow);
return { rows, total, page, limit, totalPages: Math.ceil(total / limit) };
return { rows, total, page, limit, totalPages: Math.ceil(total / limit) };
}
}
-function getRecentChatActivity(streamId, minutes) {
+async function getRecentChatActivity(streamId, minutes) {
const cutoff = new Date(Date.now() - minutes * 60 * 1000).toISOString();
const cutoff = new Date(Date.now() - minutes * 60 * 1000).toISOString();
- const row = get(
+ const row = await get(
`SELECT COUNT(*) as cnt FROM chat_messages
`SELECT COUNT(*) as cnt FROM chat_messages
WHERE stream_id = ? AND timestamp >= ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'`,
WHERE stream_id = ? AND timestamp >= ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'`,
[streamId, cutoff]
[streamId, cutoff]
@@ -479,32 +473,32 @@ function getRecentChatActivity(streamId, minutes) {
@@ -479,32 +473,32 @@ function getRecentChatActivity(streamId, minutes) {
* A user was renamed in Live (admin edit): chat_messages.username stores the display name at
* A user was renamed in Live (admin edit): chat_messages.username stores the display name at
* creation time, so Live rewrote its rows in the same request. Chat does the same for its own.
* creation time, so Live rewrote its rows in the same request. Chat does the same for its own.
*/
*/
-function renameUserChatMessages(userId, newChatName) {
- return run('UPDATE chat_messages SET username = ? WHERE user_id = ?', [newChatName, userId]);
+async function renameUserChatMessages(userId, newChatName) {
+ return await run('UPDATE chat_messages SET username = ? WHERE user_id = ?', [newChatName, userId]);
}
}
// ── Relay (external platform) chatters ───────────────────────
// ── Relay (external platform) chatters ───────────────────────
// Record a chat-relay (external platform) user's activity; keeps the earliest
// Record a chat-relay (external platform) user's activity; keeps the earliest
// first_seen as their "join date". Keyed case-insensitively by platform+username.
// first_seen as their "join date". Keyed case-insensitively by platform+username.
-function recordRelayUser(platform, username) {
+async function recordRelayUser(platform, username) {
if (!platform || !username) return;
if (!platform || !username) return;
const key = String(username).toLowerCase();
const key = String(username).toLowerCase();
try {
try {
- run(`INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count)
- VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
+ await run(`INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count)
+ VALUES (?, ?, ?, ov_now(), ov_now(), 1)
ON CONFLICT(platform, username) DO UPDATE SET
ON CONFLICT(platform, username) DO UPDATE SET
- last_seen = CURRENT_TIMESTAMP,
- message_count = message_count + 1,
+ last_seen = ov_now(),
+ message_count = relay_users.message_count + 1,
display_name = excluded.display_name`,
display_name = excluded.display_name`,
[String(platform).toLowerCase(), key, String(username)]);
[String(platform).toLowerCase(), key, String(username)]);
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
-function getRelayUser(platform, username) {
+async function getRelayUser(platform, username) {
if (!platform || !username) return null;
if (!platform || !username) return null;
// rowid is a stable integer id for a relay user (no dedicated id column); used to key
// rowid is a stable integer id for a relay user (no dedicated id column); used to key
// their chat-AI insight in chat_ai_summaries.
// their chat-AI insight in chat_ai_summaries.
- return get('SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?',
+ return await get('SELECT * FROM relay_users WHERE platform = ? AND username = ?',
[String(platform).toLowerCase(), String(username).toLowerCase()]) || null;
[String(platform).toLowerCase(), String(username).toLowerCase()]) || null;
}
}
@@ -512,18 +506,18 @@ function getRelayUser(platform, username) {
@@ -512,18 +506,18 @@ function getRelayUser(platform, username) {
// NULL user_id. Match a specific relay user by the trailing "] name" (LIKE is
// NULL user_id. Match a specific relay user by the trailing "] name" (LIKE is
// case-insensitive for ASCII in SQLite), scoped to their platform.
// case-insensitive for ASCII in SQLite), scoped to their platform.
function _likeEscape(s) { return String(s).replace(/[\\%_]/g, '\\$&'); }
function _likeEscape(s) { return String(s).replace(/[\\%_]/g, '\\$&'); }
-const _RELAY_MATCH = `cm.user_id IS NULL AND cm.source_platform = ? AND cm.username LIKE ? ESCAPE '\\'`;
+const _RELAY_MATCH = `cm.user_id IS NULL AND cm.source_platform = ? AND cm.username ILIKE ? ESCAPE '\\'`;
function _relayMatchParams(platform, rawUsername) {
function _relayMatchParams(platform, rawUsername) {
return [String(platform).toLowerCase(), '%] ' + _likeEscape(String(rawUsername))];
return [String(platform).toLowerCase(), '%] ' + _likeEscape(String(rawUsername))];
}
}
// A relay user's message history (for the "Chat Logs" viewer).
// A relay user's message history (for the "Chat Logs" viewer).
-function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0, query = '' } = {}) {
+async function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0, query = '' } = {}) {
let where = `${_RELAY_MATCH} AND cm.is_deleted = 0`;
let where = `${_RELAY_MATCH} AND cm.is_deleted = 0`;
const params = _relayMatchParams(platform, rawUsername);
const params = _relayMatchParams(platform, rawUsername);
- if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }
- const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;
- const rows = all(
+ if (query) { where += ' AND cm.message ILIKE ?'; params.push('%' + query + '%'); }
+ const total = (await get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params))?.c || 0;
+ const rows = await all(
`SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.stream_id,
`SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.stream_id,
cm.source_platform, s.title AS stream_title
cm.source_platform, s.title AS stream_title
FROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id
FROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id
@@ -536,9 +530,9 @@ function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0
@@ -536,9 +530,9 @@ function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0
/**
/**
* Hide or ban a relayed external user.
* Hide or ban a relayed external user.
*/
*/
-function hideRelayUser({ channelId, platform, externalUsername, action = 'hide', reason, createdBy }) {
- return run(
- `INSERT OR REPLACE INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by)
+async function hideRelayUser({ channelId, platform, externalUsername, action = 'hide', reason, createdBy }) {
+ return await run(
+ `INSERT INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?)`,
[channelId || null, platform, externalUsername, action, reason || null, createdBy]
[channelId || null, platform, externalUsername, action, reason || null, createdBy]
);
);
@@ -548,8 +542,8 @@ function hideRelayUser({ channelId, platform, externalUsername, action = 'hide',
@@ -548,8 +542,8 @@ function hideRelayUser({ channelId, platform, externalUsername, action = 'hide',
* Check if a relayed user is hidden/banned.
* Check if a relayed user is hidden/banned.
* Checks both channel-scoped and site-wide entries (channel_id IS NULL).
* Checks both channel-scoped and site-wide entries (channel_id IS NULL).
*/
*/
-function isRelayUserHidden(channelId, platform, externalUsername) {
- return !!get(
+async function isRelayUserHidden(channelId, platform, externalUsername) {
+ return !!await get(
`SELECT 1 FROM hidden_relay_users
`SELECT 1 FROM hidden_relay_users
WHERE platform = ? AND external_username = ? AND (channel_id = ? OR channel_id IS NULL)`,
WHERE platform = ? AND external_username = ? AND (channel_id = ? OR channel_id IS NULL)`,
[platform, externalUsername, channelId]
[platform, externalUsername, channelId]
@@ -559,8 +553,8 @@ function isRelayUserHidden(channelId, platform, externalUsername) {
@@ -559,8 +553,8 @@ function isRelayUserHidden(channelId, platform, externalUsername) {
/**
/**
* Unhide/unban a relayed external user.
* Unhide/unban a relayed external user.
*/
*/
-function unhideRelayUser(id) {
- return run('DELETE FROM hidden_relay_users WHERE id = ?', [id]);
+async function unhideRelayUser(id) {
+ return await run('DELETE FROM hidden_relay_users WHERE id = ?', [id]);
}
}
/**
/**
@@ -568,9 +562,9 @@ function unhideRelayUser(id) {
@@ -568,9 +562,9 @@ function unhideRelayUser(id) {
* channel). Scoped to a single relay identity — never touches a registered
* channel). Scoped to a single relay identity — never touches a registered
* openvibelive account of the same name. `channel_id IS ?` matches NULL safely.
* openvibelive account of the same name. `channel_id IS ?` matches NULL safely.
*/
*/
-function unhideRelayUserByIdentity(channelId, platform, externalUsername) {
- return run(
- 'DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS ?',
+async function unhideRelayUserByIdentity(channelId, platform, externalUsername) {
+ return await run(
+ 'DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS NOT DISTINCT FROM ?',
[platform, externalUsername, channelId || null]
[platform, externalUsername, channelId || null]
);
);
}
}
@@ -586,17 +580,17 @@ function anonSubjectId(anonId) {
@@ -586,17 +580,17 @@ function anonSubjectId(anonId) {
// Anon meta for the context menu: first-seen (when their anon number was assigned — Live's
// Anon meta for the context menu: first-seen (when their anon number was assigned — Live's
// anon_ip_mappings, passed in by the caller) and first-chat (their earliest chat message), plus
// anon_ip_mappings, passed in by the caller) and first-chat (their earliest chat message), plus
// total message count.
// total message count.
-function getAnonMeta(anonId, firstSeenAt = null) {
+async function getAnonMeta(anonId, firstSeenAt = null) {
const num = anonSubjectId(anonId);
const num = anonSubjectId(anonId);
- const firstChat = get(
+ const firstChat = (await get(
`SELECT MIN(timestamp) AS t FROM chat_messages
`SELECT MIN(timestamp) AS t FROM chat_messages
WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0`, [String(anonId)]
WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0`, [String(anonId)]
- )?.t || null;
- const count = get(
+ ))?.t || null;
+ const count = (await get(
`SELECT COUNT(*) AS c FROM chat_messages
`SELECT COUNT(*) AS c FROM chat_messages
WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0
WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0
- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`, [String(anonId)]
- )?.c || 0;
+ AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > ov_now())`, [String(anonId)]
+ ))?.c || 0;
return {
return {
anon_id: anonId,
anon_id: anonId,
anon_num: num || null,
anon_num: num || null,
@@ -607,13 +601,13 @@ function getAnonMeta(anonId, firstSeenAt = null) {
@@ -607,13 +601,13 @@ function getAnonMeta(anonId, firstSeenAt = null) {
}
}
// An anon's message history (for the "Chat Logs" viewer).
// An anon's message history (for the "Chat Logs" viewer).
-function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {}) {
+async function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {}) {
let where = `cm.anon_id = ? AND cm.user_id IS NULL AND cm.is_deleted = 0
let where = `cm.anon_id = ? AND cm.user_id IS NULL AND cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
+ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;
const params = [String(anonId)];
const params = [String(anonId)];
- if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }
- const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;
- const rows = all(
+ if (query) { where += ' AND cm.message ILIKE ?'; params.push('%' + query + '%'); }
+ const total = (await get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params))?.c || 0;
+ const rows = await all(
`SELECT cm.id, cm.username, cm.anon_id, cm.message, cm.message_type, cm.timestamp, cm.stream_id,
`SELECT cm.id, cm.username, cm.anon_id, cm.message, cm.message_type, cm.timestamp, cm.stream_id,
s.title AS stream_title
s.title AS stream_title
FROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id
FROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id
@@ -631,8 +625,8 @@ function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {})
@@ -631,8 +625,8 @@ function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {})
* @param {number} channelUserId - the streamer's user ID
* @param {number} channelUserId - the streamer's user ID
* @returns {boolean} true if this is their first time
* @returns {boolean} true if this is their first time
*/
*/
-function isFirstChatInChannel(chatterKey, channelUserId) {
- const row = get(
+async function isFirstChatInChannel(chatterKey, channelUserId) {
+ const row = await get(
'SELECT 1 FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',
'SELECT 1 FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',
[chatterKey, channelUserId]
[chatterKey, channelUserId]
);
);
@@ -642,9 +636,9 @@ function isFirstChatInChannel(chatterKey, channelUserId) {
@@ -642,9 +636,9 @@ function isFirstChatInChannel(chatterKey, channelUserId) {
/**
/**
* Record that a chatter has chatted in a streamer's channel.
* Record that a chatter has chatted in a streamer's channel.
*/
*/
-function recordFirstChat(chatterKey, channelUserId) {
- run(
- 'INSERT OR IGNORE INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)',
+async function recordFirstChat(chatterKey, channelUserId) {
+ await run(
+ 'INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?) ON CONFLICT DO NOTHING',
[chatterKey, channelUserId]
[chatterKey, channelUserId]
);
);
}
}
@@ -655,14 +649,14 @@ function recordFirstChat(chatterKey, channelUserId) {
@@ -655,14 +649,14 @@ function recordFirstChat(chatterKey, channelUserId) {
* Log a moderation action for auditing.
* Log a moderation action for auditing.
* Used by canvas, chat moderation, bans, etc.
* Used by canvas, chat moderation, bans, etc.
*/
*/
-function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {
- const actorSubject = subjectFor(actor_user_id);
- return transaction(() => {
- const res = run(`
+async function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {
+ const actorSubject = await subjectFor(actor_user_id);
+ return await transaction(async () => {
+ const res = await run(`
INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details, actor_subject_id)
INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details, actor_subject_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {}), actorSubject]);
`, [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {}), actorSubject]);
- _outbox().enqueue({
+ await _outbox().enqueue({
event_type: 'chat.moderation.action',
event_type: 'chat.moderation.action',
visibility: 'internal',
visibility: 'internal',
actorSubject,
actorSubject,
@@ -675,7 +669,7 @@ function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_
@@ -675,7 +669,7 @@ function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_
actor_user_id: actor_user_id || null,
actor_user_id: actor_user_id || null,
actor_subject: actorSubject,
actor_subject: actorSubject,
target_user_id: target_user_id || null,
target_user_id: target_user_id || null,
- target_subject: subjectFor(target_user_id),
+ target_subject: await subjectFor(target_user_id),
details: details || {},
details: details || {},
},
},
});
});
@@ -688,8 +682,8 @@ function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_
@@ -688,8 +682,8 @@ function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_
/**
/**
* Hold a message for IP approval.
* Hold a message for IP approval.
*/
*/
-function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, username, message }) {
- return run(
+async function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, username, message }) {
+ return await run(
`INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, user_id, anon_id, username, message)
`INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, user_id, anon_id, username, message)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[channelId, streamId, ip, userId || null, anonId || null, username, message]
[channelId, streamId, ip, userId || null, anonId || null, username, message]
@@ -700,16 +694,16 @@ function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, usern
@@ -700,16 +694,16 @@ function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, usern
* Approve or deny a pending IP message. If approved, auto-approve the IP too (approved_ips is
* Approve or deny a pending IP message. If approved, auto-approve the IP too (approved_ips is
* Live's table: the approval goes through live-context).
* Live's table: the approval goes through live-context).
*/
*/
-function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {
+async function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {
// Scoped to the channel the caller was authorised for, so a message id from another channel's
// Scoped to the channel the caller was authorised for, so a message id from another channel's
// queue matches nothing.
// queue matches nothing.
const scoped = channelId != null;
const scoped = channelId != null;
const res = scoped
const res = scoped
- ? run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?', [status, reviewedBy, id, channelId])
- : run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?', [status, reviewedBy, id]);
+ ? await run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?', [status, reviewedBy, id, channelId])
+ : await run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?', [status, reviewedBy, id]);
if (!res.changes) return;
if (!res.changes) return;
if (status === 'approved') {
if (status === 'approved') {
- const msg = get('SELECT * FROM pending_ip_messages WHERE id = ?', [id]);
+ const msg = await get('SELECT * FROM pending_ip_messages WHERE id = ?', [id]);
if (msg) _ctx().approveIp(channelId || msg.channel_id, msg.ip_address, reviewedBy, 'manual');
if (msg) _ctx().approveIp(channelId || msg.channel_id, msg.ip_address, reviewedBy, 'manual');
}
}
}
}
@@ -717,9 +711,9 @@ function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {
@@ -717,9 +711,9 @@ function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {
/**
/**
* Bulk-approve all pending messages from a specific IP in a channel.
* Bulk-approve all pending messages from a specific IP in a channel.
*/
*/
-function approveAllFromIp(channelId, ip, reviewedBy) {
+async function approveAllFromIp(channelId, ip, reviewedBy) {
_ctx().approveIp(channelId, ip, reviewedBy, 'manual');
_ctx().approveIp(channelId, ip, reviewedBy, 'manual');
- return run(
+ return await run(
"UPDATE pending_ip_messages SET status = 'approved', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
"UPDATE pending_ip_messages SET status = 'approved', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
[reviewedBy, channelId, ip]
[reviewedBy, channelId, ip]
);
);
@@ -728,94 +722,94 @@ function approveAllFromIp(channelId, ip, reviewedBy) {
@@ -728,94 +722,94 @@ function approveAllFromIp(channelId, ip, reviewedBy) {
/**
/**
* Deny all pending messages from a specific IP in a channel.
* Deny all pending messages from a specific IP in a channel.
*/
*/
-function denyAllFromIp(channelId, ip, reviewedBy) {
- return run(
+async function denyAllFromIp(channelId, ip, reviewedBy) {
+ return await run(
"UPDATE pending_ip_messages SET status = 'denied', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
"UPDATE pending_ip_messages SET status = 'denied', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
[reviewedBy, channelId, ip]
[reviewedBy, channelId, ip]
);
);
}
}
// ── Per-user TTS voice overrides (admin-set) ─────────────────
// ── Per-user TTS voice overrides (admin-set) ─────────────────
-function getTtsVoiceOverride(identityKey) {
+async function getTtsVoiceOverride(identityKey) {
try {
try {
const k = String(identityKey || '').trim().toLowerCase();
const k = String(identityKey || '').trim().toLowerCase();
if (!k) return null;
if (!k) return null;
- const r = get('SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?', [k]);
+ const r = await get('SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?', [k]);
if (!r) return null;
if (!r) return null;
return { voice: r.voice, pitch: r.pitch, speed: r.speed, gap: r.gap || 0 };
return { voice: r.voice, pitch: r.pitch, speed: r.speed, gap: r.gap || 0 };
} catch { return null; }
} catch { return null; }
}
}
-function setTtsVoiceOverride(identityKey, params, setBy) {
+async function setTtsVoiceOverride(identityKey, params, setBy) {
const k = String(identityKey || '').trim().toLowerCase();
const k = String(identityKey || '').trim().toLowerCase();
if (!k) return false;
if (!k) return false;
- run(`INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ await run(`INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ov_now())
ON CONFLICT(identity_key) DO UPDATE SET voice=excluded.voice, pitch=excluded.pitch,
ON CONFLICT(identity_key) DO UPDATE SET voice=excluded.voice, pitch=excluded.pitch,
- speed=excluded.speed, gap=excluded.gap, set_by=excluded.set_by, updated_at=CURRENT_TIMESTAMP`,
+ speed=excluded.speed, gap=excluded.gap, set_by=excluded.set_by, updated_at=ov_now()`,
[k, params.voice, params.pitch, params.speed, params.gap || 0, setBy || null]);
[k, params.voice, params.pitch, params.speed, params.gap || 0, setBy || null]);
return true;
return true;
}
}
-function deleteTtsVoiceOverride(identityKey) {
- try { run('DELETE FROM tts_voice_overrides WHERE identity_key = ?', [String(identityKey || '').trim().toLowerCase()]); return true; } catch { return false; }
+async function deleteTtsVoiceOverride(identityKey) {
+ try { await run('DELETE FROM tts_voice_overrides WHERE identity_key = ?', [String(identityKey || '').trim().toLowerCase()]); return true; } catch { return false; }
}
}
// ── Channel sound commands (viewer-uploadable) ───────────────
// ── Channel sound commands (viewer-uploadable) ───────────────
-function createChannelSound({ channel_owner_id, command, url, mime = 'audio/mpeg', duration_seconds = 0, created_by = null, created_by_name = '', emote_code = '' }) {
- return run(
+async function createChannelSound({ channel_owner_id, command, url, mime = 'audio/mpeg', duration_seconds = 0, created_by = null, created_by_name = '', emote_code = '' }) {
+ return await run(
`INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code, created_by_subject_id)
`INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code, created_by_subject_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code || '', subjectFor(created_by)]
+ [channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code || '', await subjectFor(created_by)]
);
);
}
}
// Update the shared emote_code for all sounds under a command (an emote is per-command).
// Update the shared emote_code for all sounds under a command (an emote is per-command).
-function setChannelSoundEmote(ownerId, command, emoteCode) {
- return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?',
+async function setChannelSoundEmote(ownerId, command, emoteCode) {
+ return await run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?',
[emoteCode || '', ownerId, String(command || '').toLowerCase()]);
[emoteCode || '', ownerId, String(command || '').toLowerCase()]);
}
}
-function getChannelSounds(ownerId) {
- return all(
+async function getChannelSounds(ownerId) {
+ return await all(
'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND is_approved = 1 ORDER BY command',
'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND is_approved = 1 ORDER BY command',
[ownerId]
[ownerId]
);
);
}
}
-function getChannelSoundByCommand(ownerId, command) {
+async function getChannelSoundByCommand(ownerId, command) {
// A command may have multiple uploaded sounds — pick one at random each play.
// A command may have multiple uploaded sounds — pick one at random each play.
- return get(
+ return await get(
'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND command = ? AND is_approved = 1 ORDER BY RANDOM() LIMIT 1',
'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND command = ? AND is_approved = 1 ORDER BY RANDOM() LIMIT 1',
[ownerId, String(command || '').toLowerCase()]
[ownerId, String(command || '').toLowerCase()]
);
);
}
}
-function getChannelSoundById(id) {
- return get('SELECT * FROM channel_sounds WHERE id = ?', [id]);
+async function getChannelSoundById(id) {
+ return await get('SELECT * FROM channel_sounds WHERE id = ?', [id]);
}
}
-function countChannelSounds(ownerId) {
- const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?', [ownerId]);
+async function countChannelSounds(ownerId) {
+ const row = await get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?', [ownerId]);
return row ? row.count : 0;
return row ? row.count : 0;
}
}
-function countChannelSoundsByUploader(ownerId, uploaderId) {
- const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?', [ownerId, uploaderId]);
+async function countChannelSoundsByUploader(ownerId, uploaderId) {
+ const row = await get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?', [ownerId, uploaderId]);
return row ? row.count : 0;
return row ? row.count : 0;
}
}
-function deleteChannelSound(id) {
- return run('DELETE FROM channel_sounds WHERE id = ?', [id]);
+async function deleteChannelSound(id) {
+ return await run('DELETE FROM channel_sounds WHERE id = ?', [id]);
}
}
// Rename a whole !command group (a command may hold several sounds).
// Rename a whole !command group (a command may hold several sounds).
-function renameChannelSoundCommand(ownerId, oldCommand, newCommand) {
- return run('UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?',
+async function renameChannelSoundCommand(ownerId, oldCommand, newCommand) {
+ return await run('UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?',
[String(newCommand || '').toLowerCase(), ownerId, String(oldCommand || '').toLowerCase()]);
[String(newCommand || '').toLowerCase(), ownerId, String(oldCommand || '').toLowerCase()]);
}
}
// Sounds attach emotes BY CODE — keep those references alive when an emote
// Sounds attach emotes BY CODE — keep those references alive when an emote
// is renamed so the streamer's emote+sound combos don't silently break.
// is renamed so the streamer's emote+sound combos don't silently break.
-function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {
- return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?',
+async function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {
+ return await run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?',
[newCode || '', ownerId, oldCode]);
[newCode || '', ownerId, oldCode]);
}
}
@@ -823,18 +817,18 @@ function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {
@@ -823,18 +817,18 @@ function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {
// Same SQL and shapes as Live's database.js helpers of the same name; usernames come from the
// Same SQL and shapes as Live's database.js helpers of the same name; usernames come from the
// ctx_users projection instead of Live's users table.
// ctx_users projection instead of Live's users table.
-function isChannelModerator(userId, channelId) {
- return !!get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [userId, channelId]);
+async function isChannelModerator(userId, channelId) {
+ return !!await get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [userId, channelId]);
}
}
/** The channel_moderation_settings row as it is, or null when the channel has none (defaults are Live's). */
/** The channel_moderation_settings row as it is, or null when the channel has none (defaults are Live's). */
-function getChannelModerationSettingsRow(channelId) {
- return get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)]) || null;
+async function getChannelModerationSettingsRow(channelId) {
+ return await get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)]) || null;
}
}
/** A channel's moderators, with names, oldest first (Live's getChannelModerators). */
/** A channel's moderators, with names, oldest first (Live's getChannelModerators). */
-function getChannelModerators(channelId) {
- return all(`
+async function getChannelModerators(channelId) {
+ return await all(`
SELECT cm.id, cm.user_id, cm.added_by, cm.created_at,
SELECT cm.id, cm.user_id, cm.added_by, cm.created_at,
u.username, u.display_name, u.avatar_url,
u.username, u.display_name, u.avatar_url,
a.username as added_by_username
a.username as added_by_username
@@ -847,16 +841,16 @@ function getChannelModerators(channelId) {
@@ -847,16 +841,16 @@ function getChannelModerators(channelId) {
}
}
/** The channel's moderator user ids, oldest first (Live's policy answer carries the same list). */
/** The channel's moderator user ids, oldest first (Live's policy answer carries the same list). */
-function getChannelModeratorIds(channelId) {
- return all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY created_at ASC, id ASC', [channelId]).map((r) => r.user_id);
+async function getChannelModeratorIds(channelId) {
+ return (await all('SELECT user_id FROM channel_moderators WHERE channel_id = ? ORDER BY created_at ASC, id ASC', [channelId])).map((r) => r.user_id);
}
}
/**
/**
* The channels a user moderates (Live's getChannelsByModerator). Channel title and owner come from
* The channels a user moderates (Live's getChannelsByModerator). Channel title and owner come from
* the ctx_channels / ctx_users projections; the extra `id` alias is what the dashboard reads.
* the ctx_channels / ctx_users projections; the extra `id` alias is what the dashboard reads.
*/
*/
-function getChannelsByModerator(userId) {
- return all(`
+async function getChannelsByModerator(userId) {
+ return await all(`
SELECT cm.channel_id, cm.channel_id AS id, c.title,
SELECT cm.channel_id, cm.channel_id AS id, c.title,
c.user_id AS user_id, c.user_id AS owner_user_id, u.username AS owner_username
c.user_id AS user_id, c.user_id AS owner_user_id, u.username AS owner_username
FROM channel_moderators cm
FROM channel_moderators cm
@@ -868,15 +862,15 @@ function getChannelsByModerator(userId) {
@@ -868,15 +862,15 @@ function getChannelsByModerator(userId) {
}
}
/** Custom emotes marked global (Live's getGlobalEmotes). */
/** Custom emotes marked global (Live's getGlobalEmotes). */
-function getGlobalEmotes() {
- return all(`SELECT e.*, u.username FROM emotes e
+async function getGlobalEmotes() {
+ return await all(`SELECT e.*, u.username FROM emotes e
LEFT JOIN ctx_users u ON e.user_id = u.id
LEFT JOIN ctx_users u ON e.user_id = u.id
WHERE e.is_global = 1 AND e.is_approved = 1 ORDER BY code`);
WHERE e.is_global = 1 AND e.is_approved = 1 ORDER BY code`);
}
}
/** A channel's emotes: ones targeted at the owner, plus the owner's own legacy uploads. */
/** A channel's emotes: ones targeted at the owner, plus the owner's own legacy uploads. */
-function getChannelEmotes(userId) {
- return all(
+async function getChannelEmotes(userId) {
+ return await all(
`SELECT e.*, u.username, up.username AS uploader_username, up.display_name AS uploader_display_name
`SELECT e.*, u.username, up.username AS uploader_username, up.display_name AS uploader_display_name
FROM emotes e
FROM emotes e
LEFT JOIN ctx_users u ON e.user_id = u.id
LEFT JOIN ctx_users u ON e.user_id = u.id
@@ -889,16 +883,16 @@ function getChannelEmotes(userId) {
@@ -889,16 +883,16 @@ function getChannelEmotes(userId) {
}
}
/** How many emotes a channel holds (Live's countChannelEmotes; ownerId is the streamer's user id). */
/** How many emotes a channel holds (Live's countChannelEmotes; ownerId is the streamer's user id). */
-function countChannelEmotes(ownerId) {
- return get(
+async function countChannelEmotes(ownerId) {
+ return (await get(
'SELECT COUNT(*) as count FROM emotes WHERE (channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)',
'SELECT COUNT(*) as count FROM emotes WHERE (channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)',
[ownerId, ownerId]
[ownerId, ownerId]
- )?.count || 0;
+ ))?.count || 0;
}
}
/** The channel's emote with this code, any uploader (Live's getChannelEmoteByCode). */
/** The channel's emote with this code, any uploader (Live's getChannelEmoteByCode). */
-function getChannelEmoteByCode(ownerId, code) {
- return get(
+async function getChannelEmoteByCode(ownerId, code) {
+ return await get(
`SELECT * FROM emotes
`SELECT * FROM emotes
WHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))
WHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))
LIMIT 1`,
LIMIT 1`,
@@ -907,17 +901,26 @@ function getChannelEmoteByCode(ownerId, code) {
@@ -907,17 +901,26 @@ function getChannelEmoteByCode(ownerId, code) {
}
}
/** One emote by id, with its uploader's name (Live's getEmoteById). */
/** One emote by id, with its uploader's name (Live's getEmoteById). */
-function getEmoteById(id) {
- return get('SELECT e.*, u.username FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id WHERE e.id = ?', [id]);
+async function getEmoteById(id) {
+ return await get('SELECT e.*, u.username FROM emotes e LEFT JOIN ctx_users u ON e.user_id = u.id WHERE e.id = ?', [id]);
}
}
/** A user's own emotes (Live's getEmotesByUser). */
/** A user's own emotes (Live's getEmotesByUser). */
-function getEmotesByUser(userId) {
- return all('SELECT * FROM emotes WHERE user_id = ? ORDER BY code', [userId]);
+async function getEmotesByUser(userId) {
+ return await all('SELECT * FROM emotes WHERE user_id = ? ORDER BY code', [userId]);
}
}
/** Moderation actions with actor/target names (Live's getModerationActions). */
/** Moderation actions with actor/target names (Live's getModerationActions). */
-function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_user_id, target_user_id, limit = 50, offset = 0 } = {}) {
+// Two columns SQLite declared INTEGER hold ids or names (its type affinity took both): moderation_actions.scope_id
+// (a channel/stream id or a room slug) and chat_messages.deleted_by (a user id, or the name a purge records). The
+// PostgreSQL columns are text; an id reads back as a number wherever a row leaves Chat, as it did.
+function _idBack(r, col) {
+ return r && typeof r[col] === 'string' && /^-?\d{1,15}$/.test(r[col]) ? { ...r, [col]: Number(r[col]) } : r;
+}
+function moderationRow(r) { return _idBack(r, 'scope_id'); }
+function chatMessageRow(r) { return _idBack(r, 'deleted_by'); }
+
+async function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_user_id, target_user_id, limit = 50, offset = 0 } = {}) {
const conditions = [];
const conditions = [];
const params = [];
const params = [];
const st = scopeType || scope_type;
const st = scopeType || scope_type;
@@ -928,7 +931,7 @@ function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_
@@ -928,7 +931,7 @@ function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_
if (target_user_id) { conditions.push('ma.target_user_id = ?'); params.push(target_user_id); }
if (target_user_id) { conditions.push('ma.target_user_id = ?'); params.push(target_user_id); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(limit, offset);
params.push(limit, offset);
- return all(`
+ return (await all(`
SELECT ma.*, actor.username AS actor_username, target.username AS target_username
SELECT ma.*, actor.username AS actor_username, target.username AS target_username
FROM moderation_actions ma
FROM moderation_actions ma
LEFT JOIN ctx_users actor ON ma.actor_user_id = actor.id
LEFT JOIN ctx_users actor ON ma.actor_user_id = actor.id
@@ -936,25 +939,25 @@ function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_
@@ -936,25 +939,25 @@ function getModerationActions({ scopeType, scope_type, scopeId, scope_id, actor_
${where}
${where}
ORDER BY ma.created_at DESC
ORDER BY ma.created_at DESC
LIMIT ? OFFSET ?
LIMIT ? OFFSET ?
- `, params);
+ `, params)).map(moderationRow);
}
}
/**
/**
* A channel's chat messages matching a search (Live's searchChannelChatMessages): one of the
* A channel's chat messages matching a search (Live's searchChannelChatMessages): one of the
* channel's streams, or its offline room (channel_user_id), never another channel's room.
* channel's streams, or its offline room (channel_user_id), never another channel's room.
*/
*/
-function searchChannelChatMessages(channel, { query, userId, limit = 50, offset = 0 } = {}) {
+async function searchChannelChatMessages(channel, { query, userId, limit = 50, offset = 0 } = {}) {
const conditions = [
const conditions = [
'(cm.channel_user_id = ? OR cm.stream_id IN (SELECT id FROM ctx_streams WHERE channel_id = ? OR user_id = ?))',
'(cm.channel_user_id = ? OR cm.stream_id IN (SELECT id FROM ctx_streams WHERE channel_id = ? OR user_id = ?))',
'cm.is_deleted = 0',
'cm.is_deleted = 0',
- '(cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)',
+ '(cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())',
];
];
const params = [channel.user_id, channel.id, channel.user_id];
const params = [channel.user_id, channel.id, channel.user_id];
- if (query) { conditions.push('cm.message LIKE ?'); params.push(`%${query}%`); }
+ if (query) { conditions.push('cm.message ILIKE ?'); params.push(`%${query}%`); }
if (userId) { conditions.push('cm.user_id = ?'); params.push(userId); }
if (userId) { conditions.push('cm.user_id = ?'); params.push(userId); }
params.push(limit, offset);
params.push(limit, offset);
return {
return {
- messages: all(`
+ messages: await all(`
SELECT cm.*, u.username, u.display_name, u.avatar_url
SELECT cm.*, u.username, u.display_name, u.avatar_url
FROM chat_messages cm
FROM chat_messages cm
LEFT JOIN ctx_users u ON cm.user_id = u.id
LEFT JOIN ctx_users u ON cm.user_id = u.id
@@ -985,29 +988,29 @@ function _writeResult(value, table, rows = [], deletedPks = []) {
@@ -985,29 +988,29 @@ function _writeResult(value, table, rows = [], deletedPks = []) {
}
}
// Channel moderators, settings and alert sounds (Live's /api/channels, the dashboard, /slow).
// Channel moderators, settings and alert sounds (Live's /api/channels, the dashboard, /slow).
-function addChannelModerator(channelId, userId, addedBy) {
- const out = transaction(() => {
- const res = run('INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)', [channelId, userId, addedBy]);
- return _writeResult(res, 'channel_moderators', all('SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]));
+async function addChannelModerator(channelId, userId, addedBy) {
+ const out = await transaction(async () => {
+ const res = await run('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', [channelId, userId, addedBy]);
+ return _writeResult(res, 'channel_moderators', await all('SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]));
});
});
- _ctx().invalidateChannel(channelId);
+ await _ctx().invalidateChannel(channelId);
return out;
return out;
}
}
-function removeChannelModerator(channelId, userId) {
- const out = transaction(() => {
- const gone = all('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);
- const res = run('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);
+async function removeChannelModerator(channelId, userId) {
+ const out = await transaction(async () => {
+ const gone = await all('SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);
+ const res = await run('DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?', [channelId, userId]);
return _writeResult(res, 'channel_moderators', [], gone.map((r) => ({ id: r.id })));
return _writeResult(res, 'channel_moderators', [], gone.map((r) => ({ id: r.id })));
});
});
- _ctx().invalidateChannel(channelId);
+ await _ctx().invalidateChannel(channelId);
return out;
return out;
}
}
// Live's upsertChannelModerationSettings, clamps and all.
// Live's upsertChannelModerationSettings, clamps and all.
-function upsertChannelModerationSettings(channelId, fields) {
- const out = transaction(() => {
- const existing = get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
+async function upsertChannelModerationSettings(channelId, fields) {
+ const out = await transaction(async () => {
+ const existing = await get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
if (existing) {
if (existing) {
const updates = [];
const updates = [];
const params = [];
const params = [];
@@ -1049,13 +1052,13 @@ function upsertChannelModerationSettings(channelId, fields) {
@@ -1049,13 +1052,13 @@ function upsertChannelModerationSettings(channelId, fields) {
if (fields.sound_min_pitch_cents !== undefined) set('sound_min_pitch_cents', Math.min(0, Math.max(-2400, Math.round(Number(fields.sound_min_pitch_cents) || -1200))));
if (fields.sound_min_pitch_cents !== undefined) set('sound_min_pitch_cents', Math.min(0, Math.max(-2400, Math.round(Number(fields.sound_min_pitch_cents) || -1200))));
if (fields.sound_max_pitch_cents !== undefined) set('sound_max_pitch_cents', Math.max(0, Math.min(2400, Math.round(Number(fields.sound_max_pitch_cents) || 1200))));
if (fields.sound_max_pitch_cents !== undefined) set('sound_max_pitch_cents', Math.max(0, Math.min(2400, Math.round(Number(fields.sound_max_pitch_cents) || 1200))));
if (updates.length > 0) {
if (updates.length > 0) {
- updates.push('updated_at = CURRENT_TIMESTAMP');
+ updates.push('updated_at = ov_now()');
params.push(channelId);
params.push(channelId);
- run(`UPDATE channel_moderation_settings SET ${updates.join(', ')} WHERE channel_id = ?`, params);
+ await run(`UPDATE channel_moderation_settings SET ${updates.join(', ')} WHERE channel_id = ?`, params);
}
}
} else {
} else {
const b = (v, dflt) => (v !== undefined ? (v ? 1 : 0) : dflt);
const b = (v, dflt) => (v !== undefined ? (v ? 1 : 0) : dflt);
- run(
+ await run(
`INSERT INTO channel_moderation_settings (
`INSERT INTO channel_moderation_settings (
channel_id, slow_mode_seconds, followers_only, emote_only,
channel_id, slow_mode_seconds, followers_only, emote_only,
allow_anonymous, links_allowed, gifs_enabled, account_age_gate_hours,
allow_anonymous, links_allowed, gifs_enabled, account_age_gate_hours,
@@ -1105,101 +1108,101 @@ function upsertChannelModerationSettings(channelId, fields) {
@@ -1105,101 +1108,101 @@ function upsertChannelModerationSettings(channelId, fields) {
}
}
// tts_max_length and sub_only, as Live: after the UPDATE or the fresh INSERT.
// tts_max_length and sub_only, as Live: after the UPDATE or the fresh INSERT.
if (fields.tts_max_length !== undefined) {
if (fields.tts_max_length !== undefined) {
- run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]);
+ await run('UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?', [Math.min(1000, Math.max(10, Number(fields.tts_max_length) || 200)), channelId]);
}
}
- if (fields.sub_only !== undefined) run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);
- const row = get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
+ if (fields.sub_only !== undefined) await run('UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?', [fields.sub_only ? 1 : 0, channelId]);
+ const row = await get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
return _writeResult(row, 'channel_moderation_settings', [row]);
return _writeResult(row, 'channel_moderation_settings', [row]);
});
});
- _ctx().invalidateChannel(channelId);
+ await _ctx().invalidateChannel(channelId);
return out;
return out;
}
}
// Donation / goal alert sounds live on the settings row (url = the file's path in the sounds dir).
// Donation / goal alert sounds live on the settings row (url = the file's path in the sounds dir).
-function setChannelAlertSound(channelId, kind, url, mime) {
- const out = transaction(() => {
- if (!get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId])) {
- run('INSERT INTO channel_moderation_settings (channel_id) VALUES (?)', [channelId]);
+async function setChannelAlertSound(channelId, kind, url, mime) {
+ const out = await transaction(async () => {
+ if (!await get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?', [channelId])) {
+ await run('INSERT INTO channel_moderation_settings (channel_id) VALUES (?)', [channelId]);
}
}
const col = kind === 'goal' ? 'goal_sound' : 'donation_sound';
const col = kind === 'goal' ? 'goal_sound' : 'donation_sound';
- const res = run(`UPDATE channel_moderation_settings SET ${col}_url = ?, ${col}_mime = ? WHERE channel_id = ?`, [url || null, mime || null, channelId]);
- return _writeResult(res, 'channel_moderation_settings', [get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId])]);
+ const res = await run(`UPDATE channel_moderation_settings SET ${col}_url = ?, ${col}_mime = ? WHERE channel_id = ?`, [url || null, mime || null, channelId]);
+ return _writeResult(res, 'channel_moderation_settings', [await get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [channelId])]);
});
});
- _ctx().invalidateChannel(channelId);
+ await _ctx().invalidateChannel(channelId);
return out;
return out;
}
}
// Emotes (Live's /api/emotes; media_url/media_asset_id are OpenVibe.Media's copy — Live's
// Emotes (Live's /api/emotes; media_url/media_asset_id are OpenVibe.Media's copy — Live's
// asset-sync filled them onto the shared rows, Chat uploads through its own Media token now).
// asset-sync filled them onto the shared rows, Chat uploads through its own Media token now).
-function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {
- return transaction(() => {
- const res = run(
+async function createEmote({ user_id, code, url, animated = false, width = 28, height = 28, is_global = false, channel_owner_id = null, size = 100, media_url = null, media_asset_id = null }) {
+ return await transaction(async () => {
+ const res = await run(
`INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id)
`INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size, media_url, media_asset_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100)), media_url || null, media_asset_id || null]
[user_id, code, url, animated ? 1 : 0, width, height, is_global ? 1 : 0, channel_owner_id || null, Math.min(400, Math.max(25, parseInt(size, 10) || 100)), media_url || null, media_asset_id || null]
);
);
- return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);
+ return _writeResult(res, 'emotes', [await get('SELECT * FROM emotes WHERE id = ?', [res.lastInsertRowid])]);
});
});
}
}
-function updateEmote(id, { code, size } = {}) {
+async function updateEmote(id, { code, size } = {}) {
const sets = [];
const sets = [];
const params = [];
const params = [];
if (code !== undefined) { sets.push('code = ?'); params.push(code); }
if (code !== undefined) { sets.push('code = ?'); params.push(code); }
if (size !== undefined) { sets.push('size = ?'); params.push(Math.min(400, Math.max(25, parseInt(size, 10) || 100))); }
if (size !== undefined) { sets.push('size = ?'); params.push(Math.min(400, Math.max(25, parseInt(size, 10) || 100))); }
if (!sets.length) return _writeResult({ changes: 0 }, 'emotes');
if (!sets.length) return _writeResult({ changes: 0 }, 'emotes');
- return transaction(() => {
- const res = run(`UPDATE emotes SET ${sets.join(', ')} WHERE id = ?`, [...params, id]);
- return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);
+ return await transaction(async () => {
+ const res = await run(`UPDATE emotes SET ${sets.join(', ')} WHERE id = ?`, [...params, id]);
+ return _writeResult(res, 'emotes', [await get('SELECT * FROM emotes WHERE id = ?', [id])]);
});
});
}
}
-function deleteEmote(id) {
- return transaction(() => {
- const had = get('SELECT id FROM emotes WHERE id = ?', [id]);
- const res = run('DELETE FROM emotes WHERE id = ?', [id]);
+async function deleteEmote(id) {
+ return await transaction(async () => {
+ const had = await get('SELECT id FROM emotes WHERE id = ?', [id]);
+ const res = await run('DELETE FROM emotes WHERE id = ?', [id]);
return _writeResult(res, 'emotes', [], had ? [{ id: had.id }] : []);
return _writeResult(res, 'emotes', [], had ? [{ id: had.id }] : []);
});
});
}
}
/** The emote's copy on OpenVibe.Media (Live's asset-sync). */
/** The emote's copy on OpenVibe.Media (Live's asset-sync). */
-function setEmoteMedia(id, mediaUrl, mediaAssetId) {
- return transaction(() => {
- const res = run('UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl || null, mediaAssetId || null, id]);
- return _writeResult(res, 'emotes', [get('SELECT * FROM emotes WHERE id = ?', [id])]);
+async function setEmoteMedia(id, mediaUrl, mediaAssetId) {
+ return await transaction(async () => {
+ const res = await run('UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?', [mediaUrl || null, mediaAssetId || null, id]);
+ return _writeResult(res, 'emotes', [await get('SELECT * FROM emotes WHERE id = ?', [id])]);
});
});
}
}
// User tags (owned chat tags; Live has had no writer since its game tags went read-only).
// User tags (owned chat tags; Live has had no writer since its game tags went read-only).
-function grantUserTag(userId, tagId, source = 'shop') {
- return transaction(() => {
- const res = run('INSERT OR IGNORE INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)', [userId, String(tagId), source || 'shop']);
- return _writeResult(res, 'user_tags', [get('SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)])]);
+async function grantUserTag(userId, tagId, source = 'shop') {
+ return await transaction(async () => {
+ const res = await run('INSERT INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', [userId, String(tagId), source || 'shop']);
+ return _writeResult(res, 'user_tags', [await get('SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)])]);
});
});
}
}
-function revokeUserTag(userId, tagId) {
- return transaction(() => {
- const gone = all('SELECT id FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);
- const res = run('DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);
+async function revokeUserTag(userId, tagId) {
+ return await transaction(async () => {
+ const gone = await all('SELECT id FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);
+ const res = await run('DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?', [userId, String(tagId)]);
return _writeResult(res, 'user_tags', [], gone.map((r) => ({ id: r.id })));
return _writeResult(res, 'user_tags', [], gone.map((r) => ({ id: r.id })));
});
});
}
}
// Chat AI (Live's server/ai/chat-ai.js): rolling summaries and the append-only timeline.
// Chat AI (Live's server/ai/chat-ai.js): rolling summaries and the append-only timeline.
-function upsertChatAiSummary(sfx) {
+async function upsertChatAiSummary(sfx) {
const {
const {
scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',
scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',
message_count = 0, window_message_count = 0, last_message_id = 0,
message_count = 0, window_message_count = 0, last_message_id = 0,
window_label = '', window_start = null, window_end = null,
window_label = '', window_start = null, window_end = null,
} = sfx || {};
} = sfx || {};
- return transaction(() => {
- const res = run(
+ return await transaction(async () => {
+ const res = await run(
`INSERT INTO chat_ai_summaries
`INSERT INTO chat_ai_summaries
- (scope, subject_id, window, overview, memory_json, timeline_json, message_count,
+ (scope, subject_id, "window", overview, memory_json, timeline_json, message_count,
window_message_count, last_message_id, window_label, window_start, window_end, updated_at)
window_message_count, last_message_id, window_label, window_start, window_end, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
- ON CONFLICT(scope, subject_id, window) DO UPDATE SET
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ov_now())
+ ON CONFLICT(scope, subject_id, "window") DO UPDATE SET
overview = excluded.overview,
overview = excluded.overview,
memory_json = excluded.memory_json,
memory_json = excluded.memory_json,
timeline_json = excluded.timeline_json,
timeline_json = excluded.timeline_json,
@@ -1209,29 +1212,29 @@ function upsertChatAiSummary(sfx) {
@@ -1209,29 +1212,29 @@ function upsertChatAiSummary(sfx) {
window_label = excluded.window_label,
window_label = excluded.window_label,
window_start = excluded.window_start,
window_start = excluded.window_start,
window_end = excluded.window_end,
window_end = excluded.window_end,
- updated_at = CURRENT_TIMESTAMP`,
+ updated_at = ov_now()`,
[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,
[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,
window_message_count, last_message_id, window_label, window_start, window_end]
window_message_count, last_message_id, window_label, window_start, window_end]
);
);
- return _writeResult(res, 'chat_ai_summaries', [get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?', [scope, subject_id || 0, window])]);
+ return _writeResult(res, 'chat_ai_summaries', [await get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND "window" = ?', [scope, subject_id || 0, window])]);
});
});
}
}
-function addChatTimelineEvents(scope, subjectId, events) {
+async function addChatTimelineEvents(scope, subjectId, events) {
if (!Array.isArray(events) || !events.length) return _writeResult(0, 'chat_timeline_events');
if (!Array.isArray(events) || !events.length) return _writeResult(0, 'chat_timeline_events');
- return transaction(() => {
+ return await transaction(async () => {
let n = 0;
let n = 0;
const ids = [];
const ids = [];
for (const e of events) {
for (const e of events) {
if (!e || !e.label || !e.ts) continue;
if (!e || !e.label || !e.ts) continue;
try {
try {
- const res = run('INSERT OR IGNORE INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?)',
+ const res = await run('INSERT INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',
[scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400)]);
[scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400)]);
if (res.changes) ids.push(Number(res.lastInsertRowid));
if (res.changes) ids.push(Number(res.lastInsertRowid));
n++; // Live counts every event it tried, the duplicates included
n++; // Live counts every event it tried, the duplicates included
} catch { /* */ }
} catch { /* */ }
}
}
- return _writeResult(n, 'chat_timeline_events', ids.map((id) => get('SELECT * FROM chat_timeline_events WHERE id = ?', [id])));
+ return _writeResult(n, 'chat_timeline_events', (await Promise.all(ids.map(async (id) => await get('SELECT * FROM chat_timeline_events WHERE id = ?', [id])))));
});
});
}
}
@@ -1240,42 +1243,42 @@ function addChatTimelineEvents(scope, subjectId, events) {
@@ -1240,42 +1243,42 @@ function addChatTimelineEvents(scope, subjectId, events) {
// the ctx_* projections here (Live's rows are not in this database).
// the ctx_* projections here (Live's rows are not in this database).
const _CHAT_AI_WHERE = `cm.is_deleted = 0 AND cm.message_type != 'system'
const _CHAT_AI_WHERE = `cm.is_deleted = 0 AND cm.message_type != 'system'
AND COALESCE(cm.source_platform,'') != 'ai'
AND COALESCE(cm.source_platform,'') != 'ai'
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
+ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > ov_now())`;
-function getChatAiSummary(scope, subjectId, window) {
- return get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?',
+async function getChatAiSummary(scope, subjectId, window) {
+ return await get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND "window" = ?',
[scope, subjectId || 0, window]) || null;
[scope, subjectId || 0, window]) || null;
}
}
-function getChatAiSummaries(scope, subjectId) {
- return all('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? ORDER BY window',
+async function getChatAiSummaries(scope, subjectId) {
+ return await all('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? ORDER BY "window"',
[scope, subjectId || 0]);
[scope, subjectId || 0]);
}
}
// Paginated + searchable timeline browse. `before` = epoch ms (exclusive upper bound); `q`
// Paginated + searchable timeline browse. `before` = epoch ms (exclusive upper bound); `q`
// filters label/detail; `since` = epoch ms lower bound (for period jumps). Newest first.
// filters label/detail; `since` = epoch ms lower bound (for period jumps). Newest first.
-function getChatTimelineEvents({ scope = 'global', subjectId = 0, before = null, since = null, q = null, limit = 25 } = {}) {
+async function getChatTimelineEvents({ scope = 'global', subjectId = 0, before = null, since = null, q = null, limit = 25 } = {}) {
const conds = ['scope = ?', 'subject_id = ?'];
const conds = ['scope = ?', 'subject_id = ?'];
const params = [scope, subjectId || 0];
const params = [scope, subjectId || 0];
- if (before) { conds.push("ts < datetime(?, 'unixepoch')"); params.push(Math.floor(before / 1000)); }
- if (since) { conds.push("ts >= datetime(?, 'unixepoch')"); params.push(Math.floor(since / 1000)); }
- if (q && String(q).trim()) { const like = '%' + String(q).trim().slice(0, 60) + '%'; conds.push('(label LIKE ? OR detail LIKE ?)'); params.push(like, like); }
+ if (before) { conds.push("ts < to_char(to_timestamp(?) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')"); params.push(Math.floor(before / 1000)); }
+ if (since) { conds.push("ts >= to_char(to_timestamp(?) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')"); params.push(Math.floor(since / 1000)); }
+ if (q && String(q).trim()) { const like = '%' + String(q).trim().slice(0, 60) + '%'; conds.push('(label ILIKE ? OR detail ILIKE ?)'); params.push(like, like); }
params.push(Math.min(60, Math.max(1, limit)));
params.push(Math.min(60, Math.max(1, limit)));
try {
try {
- return all(`SELECT id, ts, label, detail FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];
+ return await all(`SELECT id, ts, label, detail FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];
} catch { return []; }
} catch { return []; }
}
}
-function getMaxChatMessageId() {
- return get('SELECT MAX(id) AS m FROM chat_messages')?.m || 0;
+async function getMaxChatMessageId() {
+ return (await get('SELECT MAX(id) AS m FROM chat_messages'))?.m || 0;
}
}
// Count analyzable messages newer than a high-water id (optionally for one user).
// Count analyzable messages newer than a high-water id (optionally for one user).
-function countChatMessagesSince(afterId, userId = null) {
+async function countChatMessagesSince(afterId, userId = null) {
let sql = `SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${_CHAT_AI_WHERE} AND cm.id > ?`;
let sql = `SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${_CHAT_AI_WHERE} AND cm.id > ?`;
const params = [afterId || 0];
const params = [afterId || 0];
if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
- return get(sql, params)?.c || 0;
+ return (await get(sql, params))?.c || 0;
}
}
// Fetch analyzable messages for AI batching (with the channel/broadcaster label).
// Fetch analyzable messages for AI batching (with the channel/broadcaster label).
-function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {
+async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {
let sql = `SELECT cm.id, cm.user_id, cm.username, cm.message, cm.message_type, cm.timestamp,
let sql = `SELECT cm.id, cm.user_id, cm.username, cm.message, cm.message_type, cm.timestamp,
cm.stream_id, cm.channel_user_id, cm.is_global,
cm.stream_id, cm.channel_user_id, cm.is_global,
ch.username AS channel_username, ch.display_name AS channel_display
ch.username AS channel_username, ch.display_name AS channel_display
@@ -1288,41 +1291,41 @@ function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, l
@@ -1288,41 +1291,41 @@ function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, l
if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
params.push(Math.max(1, Math.min(2000, limit)));
params.push(Math.max(1, Math.min(2000, limit)));
- const rows = all(sql, params);
+ const rows = await all(sql, params);
return order === 'desc' ? rows.reverse() : rows;
return order === 'desc' ? rows.reverse() : rows;
}
}
// Timestamp of the Nth-most-recent analyzable message — drives the adaptive overview window.
// Timestamp of the Nth-most-recent analyzable message — drives the adaptive overview window.
-function getNthRecentChatTs(n, userId = null) {
+async function getNthRecentChatTs(n, userId = null) {
let sql = `SELECT cm.timestamp AS ts FROM chat_messages cm WHERE ${_CHAT_AI_WHERE}`;
let sql = `SELECT cm.timestamp AS ts FROM chat_messages cm WHERE ${_CHAT_AI_WHERE}`;
const params = [];
const params = [];
if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
if (userId) { sql += ' AND cm.user_id = ?'; params.push(userId); }
sql += ' ORDER BY cm.id DESC LIMIT 1 OFFSET ?';
sql += ' ORDER BY cm.id DESC LIMIT 1 OFFSET ?';
params.push(Math.max(0, (n | 0) - 1));
params.push(Math.max(0, (n | 0) - 1));
- return get(sql, params)?.ts || null;
+ return (await get(sql, params))?.ts || null;
}
}
// Users with enough new chat activity (or a stale summary) to warrant an AI refresh.
// Users with enough new chat activity (or a stale summary) to warrant an AI refresh.
-function getUsersNeedingChatAi({ threshold = 15, staleCutoffIso, sinceTs, limit = 3 } = {}) {
+async function getUsersNeedingChatAi({ threshold = 15, staleCutoffIso, sinceTs, limit = 3 } = {}) {
const sql = `
const sql = `
SELECT cm.user_id AS uid,
SELECT cm.user_id AS uid,
MAX(cm.id) AS max_id,
MAX(cm.id) AS max_id,
- SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS new_msgs,
+ CAST(SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS BIGINT) AS new_msgs,
COALESCE(cs.last_message_id, 0) AS hw,
COALESCE(cs.last_message_id, 0) AS hw,
cs.updated_at AS last_update
cs.updated_at AS last_update
FROM chat_messages cm
FROM chat_messages cm
LEFT JOIN chat_ai_summaries cs
LEFT JOIN chat_ai_summaries cs
- ON cs.scope = 'user' AND cs.subject_id = cm.user_id AND cs.window = 'rolling'
+ ON cs.scope = 'user' AND cs.subject_id = cm.user_id AND cs."window" = 'rolling'
WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NOT NULL AND cm.timestamp >= ?
WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NOT NULL AND cm.timestamp >= ?
- GROUP BY cm.user_id
- HAVING new_msgs > 0
- AND ( new_msgs >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )
+ GROUP BY cm.user_id, cs.last_message_id, cs.updated_at
+ HAVING SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) > 0
+ AND ( SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )
ORDER BY (cs.updated_at IS NULL) DESC, new_msgs DESC
ORDER BY (cs.updated_at IS NULL) DESC, new_msgs DESC
LIMIT ?`;
LIMIT ?`;
- return all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
+ return await all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
}
}
// Relay messages for AI batching (mirrors getChatMessagesForAi).
// Relay messages for AI batching (mirrors getChatMessagesForAi).
-function getRelayChatMessagesForAi({ platform, rawUsername, sinceTs = null, limit = 300, order = 'asc' } = {}) {
+async function getRelayChatMessagesForAi({ platform, rawUsername, sinceTs = null, limit = 300, order = 'asc' } = {}) {
let sql = `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.source_platform
let sql = `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.source_platform
FROM chat_messages cm
FROM chat_messages cm
WHERE ${_CHAT_AI_WHERE} AND ${_RELAY_MATCH}`;
WHERE ${_CHAT_AI_WHERE} AND ${_RELAY_MATCH}`;
@@ -1330,18 +1333,18 @@ function getRelayChatMessagesForAi({ platform, rawUsername, sinceTs = null, limi
@@ -1330,18 +1333,18 @@ function getRelayChatMessagesForAi({ platform, rawUsername, sinceTs = null, limi
if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
params.push(Math.max(1, Math.min(2000, limit)));
params.push(Math.max(1, Math.min(2000, limit)));
- const rows = all(sql, params);
+ const rows = await all(sql, params);
return order === 'desc' ? rows.reverse() : rows;
return order === 'desc' ? rows.reverse() : rows;
}
}
// Relay users with new activity since their last AI summary (or never summarised).
// Relay users with new activity since their last AI summary (or never summarised).
-function getRelayUsersNeedingChatAi({ lookbackIso, threshold = 8, limit = 2 } = {}) {
- return all(`
- SELECT r.rowid AS id, r.platform, r.username, r.display_name, r.message_count, r.last_seen,
+async function getRelayUsersNeedingChatAi({ lookbackIso, threshold = 8, limit = 2 } = {}) {
+ return await all(`
+ SELECT r.id, r.platform, r.username, r.display_name, r.message_count, r.last_seen,
cs.updated_at AS last_update
cs.updated_at AS last_update
FROM relay_users r
FROM relay_users r
LEFT JOIN chat_ai_summaries cs
LEFT JOIN chat_ai_summaries cs
- ON cs.scope = 'relay' AND cs.window = 'rolling' AND cs.subject_id = r.rowid
+ ON cs.scope = 'relay' AND cs."window" = 'rolling' AND cs.subject_id = r.id
WHERE r.last_seen >= ?
WHERE r.last_seen >= ?
AND r.message_count >= ?
AND r.message_count >= ?
AND (cs.updated_at IS NULL OR cs.updated_at < r.last_seen)
AND (cs.updated_at IS NULL OR cs.updated_at < r.last_seen)
@@ -1350,7 +1353,7 @@ function getRelayUsersNeedingChatAi({ lookbackIso, threshold = 8, limit = 2 } =
@@ -1350,7 +1353,7 @@ function getRelayUsersNeedingChatAi({ lookbackIso, threshold = 8, limit = 2 } =
}
}
// Anon messages for AI batching (mirrors getChatMessagesForAi / getRelayChatMessagesForAi).
// Anon messages for AI batching (mirrors getChatMessagesForAi / getRelayChatMessagesForAi).
-function getAnonChatMessagesForAi({ anonId, sinceTs = null, limit = 300, order = 'asc' } = {}) {
+async function getAnonChatMessagesForAi({ anonId, sinceTs = null, limit = 300, order = 'asc' } = {}) {
let sql = `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp
let sql = `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp
FROM chat_messages cm
FROM chat_messages cm
WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NULL AND cm.anon_id = ?`;
WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NULL AND cm.anon_id = ?`;
@@ -1358,44 +1361,48 @@ function getAnonChatMessagesForAi({ anonId, sinceTs = null, limit = 300, order =
@@ -1358,44 +1361,48 @@ function getAnonChatMessagesForAi({ anonId, sinceTs = null, limit = 300, order =
if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
if (sinceTs != null) { sql += ' AND cm.timestamp >= ?'; params.push(sinceTs); }
sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
sql += ` ORDER BY cm.id ${order === 'desc' ? 'DESC' : 'ASC'} LIMIT ?`;
params.push(Math.max(1, Math.min(2000, limit)));
params.push(Math.max(1, Math.min(2000, limit)));
- const rows = all(sql, params);
+ const rows = await all(sql, params);
return order === 'desc' ? rows.reverse() : rows;
return order === 'desc' ? rows.reverse() : rows;
}
}
// Anons with enough new chat activity (or a stale summary) to warrant an AI refresh.
// Anons with enough new chat activity (or a stale summary) to warrant an AI refresh.
-function getAnonsNeedingChatAi({ threshold = 12, staleCutoffIso, sinceTs, limit = 2 } = {}) {
+async function getAnonsNeedingChatAi({ threshold = 12, staleCutoffIso, sinceTs, limit = 2 } = {}) {
const sql = `
const sql = `
SELECT cm.anon_id AS anon_id,
SELECT cm.anon_id AS anon_id,
MAX(cm.id) AS max_id,
MAX(cm.id) AS max_id,
- SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS new_msgs
+ CAST(SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) AS BIGINT) AS new_msgs
FROM chat_messages cm
FROM chat_messages cm
LEFT JOIN chat_ai_summaries cs
LEFT JOIN chat_ai_summaries cs
- ON cs.scope = 'anon' AND cs.window = 'rolling'
- AND cs.subject_id = CAST(SUBSTR(cm.anon_id, 5) AS INTEGER)
+ ON cs.scope = 'anon' AND cs."window" = 'rolling'
+ AND cs.subject_id = CASE WHEN cm.anon_id ~ '^anon[0-9]{1,15}$' THEN CAST(SUBSTR(cm.anon_id, 5) AS BIGINT) END
WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NULL AND cm.anon_id IS NOT NULL
WHERE ${_CHAT_AI_WHERE} AND cm.user_id IS NULL AND cm.anon_id IS NOT NULL
- AND cm.anon_id LIKE 'anon%' AND cm.timestamp >= ?
- GROUP BY cm.anon_id
- HAVING new_msgs > 0
- AND ( new_msgs >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )
+ AND cm.anon_id ILIKE 'anon%' AND cm.timestamp >= ?
+ GROUP BY cm.anon_id, cs.last_message_id, cs.updated_at
+ HAVING SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) > 0
+ AND ( SUM(CASE WHEN cm.id > COALESCE(cs.last_message_id, 0) THEN 1 ELSE 0 END) >= ? OR cs.last_message_id IS NULL OR cs.updated_at IS NULL OR cs.updated_at < ? )
ORDER BY (cs.updated_at IS NULL) DESC, new_msgs DESC
ORDER BY (cs.updated_at IS NULL) DESC, new_msgs DESC
LIMIT ?`;
LIMIT ?`;
- return all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
+ return await all(sql, [sinceTs, threshold, staleCutoffIso, Math.max(1, limit)]);
}
}
// ── Meta ─────────────────────────────────────────────────────
// ── Meta ─────────────────────────────────────────────────────
-function getMeta(key) { return get('SELECT value FROM chat_meta WHERE key = ?', [key])?.value ?? null; }
-function setMeta(key, value) { return run('INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value == null ? null : String(value)]); }
+async function getMeta(key) { return (await get('SELECT value FROM chat_meta WHERE key = ?', [key]))?.value ?? null; }
+async function setMeta(key, value) { return await run('INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value == null ? null : String(value)]); }
module.exports = {
module.exports = {
CHAT_TABLES,
CHAT_TABLES,
+ moderationRow,
+ chatMessageRow,
+ openDb,
getDb,
getDb,
initDb,
initDb,
- installMirrorTriggers,
+ setDb,
close,
close,
run,
run,
get,
get,
all,
all,
transaction,
transaction,
+ tx: transaction,
subjectFor,
subjectFor,
getMeta,
getMeta,
setMeta,
setMeta,
deleted file mode 100644
deleted file mode 100644
@@ -1,514 +0,0 @@
@@ -1,514 +0,0 @@
-
-
-CREATE TABLE IF NOT EXISTS chat_messages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- stream_id INTEGER, -- Live stream (session) id
- user_id INTEGER, -- Live user id; 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, -- the broadcaster whose channel room this is
- subject_id TEXT -- Network subject of the author (usr_…), when known
-);
-CREATE INDEX IF NOT EXISTS idx_chat_stream_id ON chat_messages(stream_id);
-CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_messages(timestamp);
-CREATE INDEX IF NOT EXISTS idx_chat_user_id ON chat_messages(user_id);
-CREATE INDEX IF NOT EXISTS idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp);
-CREATE INDEX IF NOT EXISTS idx_chat_stream_ts ON chat_messages(stream_id, timestamp);
-CREATE INDEX IF NOT EXISTS idx_chat_autodelete ON chat_messages(auto_delete_at);
-CREATE INDEX IF NOT EXISTS idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted);
-CREATE INDEX IF NOT EXISTS idx_chat_page_live ON chat_messages(id DESC) WHERE is_deleted = 0;
-CREATE INDEX IF NOT EXISTS idx_chat_anon ON chat_messages(anon_id);
-
-CREATE TABLE IF NOT EXISTS 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
-);
-
-CREATE TABLE IF NOT EXISTS 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,
- subject_id TEXT,
- UNIQUE(conversation_id, user_id),
- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE
-);
-CREATE INDEX IF NOT EXISTS idx_dm_participants_conv ON dm_participants(conversation_id);
-CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id);
-
-CREATE TABLE IF NOT EXISTS 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,
- sender_subject_id TEXT,
- FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE
-);
-CREATE INDEX IF NOT EXISTS idx_dm_messages_conv ON dm_messages(conversation_id, created_at);
-CREATE INDEX IF NOT EXISTS idx_dm_messages_sender ON dm_messages(sender_id);
-
-CREATE TABLE IF NOT EXISTS dm_blocks (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- blocker_id INTEGER NOT NULL,
- blocked_id INTEGER NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- blocker_subject_id TEXT,
- UNIQUE(blocker_id, blocked_id)
-);
-CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocker ON dm_blocks(blocker_id);
-CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocked ON dm_blocks(blocked_id);
-
-CREATE TABLE IF NOT EXISTS tts_voice_overrides (
- identity_key TEXT PRIMARY KEY,
- voice TEXT,
- pitch INTEGER,
- speed INTEGER,
- gap INTEGER DEFAULT 0,
- set_by INTEGER,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE TABLE IF NOT EXISTS 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, -- on-disk path of the audio file
- 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 '',
- created_by_subject_id TEXT
- -- Multiple sounds may share a command; playback picks one at random.
- -- media_url / media_asset_id stay on Live's copy (its asset-sync mirrors files to Media).
-);
-CREATE INDEX IF NOT EXISTS idx_channel_sounds_owner ON channel_sounds(channel_owner_id);
-CREATE INDEX IF NOT EXISTS idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);
-
-CREATE TABLE IF NOT EXISTS relay_users (
- platform TEXT NOT NULL,
- username TEXT NOT NULL,
- display_name TEXT,
- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
- message_count INTEGER DEFAULT 0,
- PRIMARY KEY (platform, username)
-);
-
-CREATE TABLE IF NOT EXISTS 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
-);
-CREATE INDEX IF NOT EXISTS idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform);
-
-CREATE TABLE IF NOT EXISTS 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
-);
-CREATE INDEX IF NOT EXISTS idx_pending_ip_channel ON pending_ip_messages(channel_id, status);
-
-CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_sfc_channel ON stream_first_chats(channel_user_id);
-
-CREATE TABLE IF NOT EXISTS 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,
- actor_subject_id TEXT
-);
-CREATE INDEX IF NOT EXISTS idx_mod_actions_created ON moderation_actions(created_at DESC);
-CREATE INDEX IF NOT EXISTS idx_mod_actions_actor ON moderation_actions(actor_user_id);
-CREATE INDEX IF NOT EXISTS idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);
-
-
-CREATE TABLE IF NOT EXISTS 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)
-);
-CREATE INDEX IF NOT EXISTS idx_channel_mods_channel ON channel_moderators(channel_id);
-CREATE INDEX IF NOT EXISTS idx_channel_mods_user ON channel_moderators(user_id);
-
-CREATE TABLE IF NOT EXISTS 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,
- custom_sounds_enabled INTEGER DEFAULT 1,
- max_sound_seconds INTEGER DEFAULT 10,
- uploads_mods_only INTEGER DEFAULT 0,
- mods_can_edit_about INTEGER DEFAULT 0,
- donation_sound_url TEXT,
- donation_sound_mime TEXT,
- goal_sound_url TEXT,
- goal_sound_mime TEXT,
- emote_scale INTEGER DEFAULT 100,
- 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,
- sub_only INTEGER DEFAULT 0 -- sub-only chat: active subscribers and the room's moderators
-);
-
-CREATE TABLE IF NOT EXISTS 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,
- media_url TEXT,
- media_asset_id INTEGER
-);
-CREATE INDEX IF NOT EXISTS idx_emotes_user ON emotes(user_id);
-CREATE INDEX IF NOT EXISTS idx_emotes_channel_owner ON emotes(channel_owner_id);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code);
-
-CREATE TABLE IF NOT EXISTS user_tags (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- tag_id TEXT NOT NULL,
- source TEXT DEFAULT 'shop',
- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(user_id, tag_id)
-);
-CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id);
-
-CREATE TABLE IF NOT EXISTS 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 TABLE IF NOT EXISTS 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 UNIQUE INDEX IF NOT EXISTS idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label);
-
-
-CREATE TABLE IF NOT EXISTS ctx_users (
- id INTEGER PRIMARY KEY,
- username TEXT,
- display_name TEXT,
- avatar_url TEXT,
- profile_color TEXT,
- role TEXT DEFAULT 'user',
- is_banned INTEGER DEFAULT 0,
- ban_reason TEXT,
- is_owner INTEGER DEFAULT 0,
- created_at DATETIME,
- subject_id TEXT,
- synced_at INTEGER
-);
-CREATE INDEX IF NOT EXISTS idx_ctx_users_username ON ctx_users(username COLLATE NOCASE);
-CREATE INDEX IF NOT EXISTS idx_ctx_users_display ON ctx_users(display_name COLLATE NOCASE);
-
-CREATE TABLE IF NOT EXISTS ctx_streams (
- id INTEGER PRIMARY KEY,
- user_id INTEGER,
- channel_id INTEGER,
- managed_stream_id INTEGER,
- title TEXT,
- is_live INTEGER DEFAULT 0,
- started_at DATETIME,
- ended_at DATETIME,
- created_at DATETIME,
- synced_at INTEGER
-);
-CREATE INDEX IF NOT EXISTS idx_ctx_streams_user ON ctx_streams(user_id, id);
-CREATE INDEX IF NOT EXISTS idx_ctx_streams_channel ON ctx_streams(channel_id);
-CREATE INDEX IF NOT EXISTS idx_ctx_streams_managed ON ctx_streams(managed_stream_id);
-
-CREATE TABLE IF NOT EXISTS ctx_managed_streams (
- id INTEGER PRIMARY KEY,
- user_id INTEGER,
- slug TEXT,
- title TEXT,
- sort_order INTEGER DEFAULT 0,
- created_at DATETIME,
- synced_at INTEGER
-);
-CREATE INDEX IF NOT EXISTS idx_ctx_ms_user ON ctx_managed_streams(user_id);
-
-CREATE TABLE IF NOT EXISTS ctx_channels (
- id INTEGER PRIMARY KEY,
- user_id INTEGER UNIQUE,
- title TEXT,
- emote_sources TEXT, -- the channel's emote source switches (Live's channels.emote_sources)
- synced_at INTEGER
-);
-
-CREATE TABLE IF NOT EXISTS ctx_sync (
- key TEXT PRIMARY KEY,
- value TEXT,
- updated_at INTEGER
-);
-
-CREATE TABLE IF NOT EXISTS events_outbox (
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
- event_id TEXT NOT NULL UNIQUE,
- event_type TEXT NOT NULL,
- event TEXT NOT NULL,
- created_at TEXT NOT NULL,
- sent_at TEXT,
- attempts INTEGER DEFAULT 0,
- last_error TEXT
-);
-CREATE INDEX IF NOT EXISTS idx_events_outbox_unsent ON events_outbox(sent_at, seq);
-
-CREATE TABLE IF NOT EXISTS live_mirror_outbox (
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
- tbl TEXT NOT NULL,
- op TEXT NOT NULL CHECK(op IN ('upsert', 'delete')),
- pk TEXT NOT NULL,
- created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
-);
-
-CREATE TABLE IF NOT EXISTS bridge_applied (
- key TEXT PRIMARY KEY,
- result TEXT,
- applied_at INTEGER NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS audio_requests (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- room TEXT NOT NULL,
- stream_id INTEGER,
- channel_user_id INTEGER,
- kind TEXT NOT NULL CHECK(kind IN ('tts', 'channel-sound', 'soundboard')),
- state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'playing', 'played', 'skipped', 'failed')),
- requested_by TEXT,
- identity_key TEXT,
- label TEXT,
- payload TEXT NOT NULL DEFAULT '{}',
- dedupe_key TEXT,
- attempts INTEGER NOT NULL DEFAULT 0,
- duration_ms INTEGER,
- error TEXT,
- actor TEXT,
- created_at INTEGER NOT NULL,
- started_at INTEGER,
- finished_at INTEGER,
- UNIQUE(room, dedupe_key)
-);
-CREATE INDEX IF NOT EXISTS idx_audio_requests_room_state ON audio_requests(room, state, id);
-
-CREATE TABLE IF NOT EXISTS bridge_refs (
- boot TEXT NOT NULL,
- ref INTEGER NOT NULL,
- id INTEGER NOT NULL,
- at INTEGER NOT NULL,
- PRIMARY KEY (boot, ref)
-);
-
-CREATE TABLE IF NOT EXISTS import_hold (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- source_table TEXT NOT NULL,
- source_pk TEXT NOT NULL,
- reason TEXT NOT NULL,
- row_json TEXT NOT NULL,
- held_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(source_table, source_pk)
-);
-
-CREATE TABLE IF NOT EXISTS import_runs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- live_db TEXT,
- dry_run INTEGER DEFAULT 0,
- counts TEXT,
- started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- finished_at DATETIME
-);
-
-CREATE TABLE IF NOT EXISTS chat_meta (
- key TEXT PRIMARY KEY,
- value TEXT
-);
-
-CREATE TABLE IF NOT EXISTS deploy_releases (
- head TEXT PRIMARY KEY,
- message_id INTEGER,
- first_via TEXT NOT NULL CHECK(first_via IN ('bridge', 'events')),
- bridge_at INTEGER,
- event_at INTEGER,
- event_id TEXT,
- commit_count INTEGER NOT NULL DEFAULT 0,
- created_at INTEGER NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS chat_event_inbox (
- consumer TEXT NOT NULL,
- event_id TEXT NOT NULL,
- processed_at INTEGER NOT NULL,
- PRIMARY KEY (consumer, event_id)
-);
-
-CREATE TABLE IF NOT EXISTS calls (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- kind TEXT NOT NULL CHECK(kind IN ('channel', 'direct', 'stream')),
- state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'ringing', 'active', 'ended', 'missed', 'declined', 'failed')),
- channel_id TEXT NOT NULL,
- stream_id INTEGER,
- created_by INTEGER,
- created_by_subject TEXT,
- target_user_id INTEGER,
- target_subject TEXT,
- end_reason TEXT,
- created_at INTEGER NOT NULL,
- started_at INTEGER,
- answered_at INTEGER,
- ended_at INTEGER
-);
-CREATE INDEX IF NOT EXISTS idx_calls_channel_state ON calls(channel_id, state);
-CREATE INDEX IF NOT EXISTS idx_calls_open ON calls(state) WHERE state IN ('pending', 'ringing', 'active');
@@ -35,7 +35,7 @@
@@ -35,7 +35,7 @@
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
-const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const db = require('../db/database');
const db = require('../db/database');
const { viaProxy } = require('../net/service-auth');
const { viaProxy } = require('../net/service-auth');
const deployNotice = require('../chat/deploy-notice');
const deployNotice = require('../chat/deploy-notice');
@@ -59,8 +59,7 @@ const INBOX_KEEP_MS = 35 * 24 * 3600 * 1000; // Events keeps events 30 day
@@ -59,8 +59,7 @@ const INBOX_KEEP_MS = 35 * 24 * 3600 * 1000; // Events keeps events 30 day
*/
*/
function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now(), releaseMaxAgeMs = deployNotice.RELEASE_MAX_AGE_MS, log = console } = {}) {
function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now(), releaseMaxAgeMs = deployNotice.RELEASE_MAX_AGE_MS, log = console } = {}) {
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
- const inbox = createInbox(db.getDb(), { table: INBOX_TABLE, now });
- inbox.ensureSchema();
+ const inbox = createPgInbox(db.getDb(), { table: INBOX_TABLE, now }); // the table is in migrations/0001_initial.sql
const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null, last_type: null, last_outcome: null, last_error: null };
const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null, last_type: null, last_outcome: null, last_error: null };
/**
/**
@@ -71,7 +70,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
@@ -71,7 +70,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
if (event.event_type === 'live.release.deployed') {
if (event.event_type === 'live.release.deployed') {
const rel = deployNotice.releaseFrom(event, { now: now(), maxAgeMs: releaseMaxAgeMs });
const rel = deployNotice.releaseFrom(event, { now: now(), maxAgeMs: releaseMaxAgeMs });
if (typeof rel === 'string') return rel;
if (typeof rel === 'string') return rel;
- return () => deployNotice.applyReleaseEvent({ db, chatServer, event, now: now(), maxAgeMs: releaseMaxAgeMs, log });
+ return async () => await deployNotice.applyReleaseEvent({ db, chatServer, event, now: now(), maxAgeMs: releaseMaxAgeMs, log });
}
}
if (event.event_type === 'network.module.updated') {
if (event.event_type === 'network.module.updated') {
if (event.source !== 'network') return 'ignored:source';
if (event.source !== 'network') return 'ignored:source';
@@ -93,13 +92,13 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
@@ -93,13 +92,13 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
const subject = p.subject && p.subject.id;
const subject = p.subject && p.subject.id;
const ms = Date.parse(p.valid_after);
const ms = Date.parse(p.valid_after);
if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';
if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';
- return () => {
- const moved = revocations.record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null, now());
+ return async () => {
+ const moved = await revocations.record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null, now());
if (!moved) return 'unchanged';
if (!moved) return 'unchanged';
return {
return {
outcome: 'revoked',
outcome: 'revoked',
- after: () => {
- for (const r of db.all('SELECT id FROM ctx_users WHERE subject_id = ?', [subject])) ctx.invalidateUser(r.id);
+ after: async () => {
+ for (const r of await db.all('SELECT id FROM ctx_users WHERE subject_id = ?', [subject])) ctx.invalidateUser(r.id);
const closed = chatServer ? chatServer.revokeSubject(subject, ms) : 0;
const closed = chatServer ? chatServer.revokeSubject(subject, ms) : 0;
if (closed) log.log && log.log(`[Events consumer] ${p.reason || 'revoked'}: closed ${closed} socket(s)`);
if (closed) log.log && log.log(`[Events consumer] ${p.reason || 'revoked'}: closed ${closed} socket(s)`);
},
},
@@ -111,7 +110,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
@@ -111,7 +110,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
if (event.source !== 'network') return 'ignored:source';
if (event.source !== 'network') return 'ignored:source';
const p = networkBlocks.payloadOf(event);
const p = networkBlocks.payloadOf(event);
if (!p) return 'ignored:payload';
if (!p) return 'ignored:payload';
- return () => (networkBlocks.apply(p, now()) ? (p.active ? 'blocked' : 'unblocked') : 'unchanged');
+ return async () => (await networkBlocks.apply(p, now()) ? (p.active ? 'blocked' : 'unblocked') : 'unchanged');
}
}
if (event.event_type === 'network.subject.merged') {
if (event.event_type === 'network.subject.merged') {
// Two accounts became one (ADR-029): the folded-in account's messages, DMs, rooms and blocks are the survivor's.
// Two accounts became one (ADR-029): the folded-in account's messages, DMs, rooms and blocks are the survivor's.
@@ -119,7 +118,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
@@ -119,7 +118,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
const merge = require('../chat/subject-merge');
const merge = require('../chat/subject-merge');
const p = merge.payloadOf(event);
const p = merge.payloadOf(event);
if (!p) return 'ignored:payload';
if (!p) return 'ignored:payload';
- return () => merge.apply(p);
+ return async () => await merge.apply(p);
}
}
if (event.event_type === 'vip.membership.changed') {
if (event.event_type === 'vip.membership.changed') {
// A membership started, lapsed or was revoked: drop that member's cached badge answers for
// A membership started, lapsed or was revoked: drop that member's cached badge answers for
@@ -131,24 +130,24 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
@@ -131,24 +130,24 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
}
}
/** Apply one envelope. Returns { duplicate, outcome, detail? }. Throws only on a storage failure. */
/** Apply one envelope. Returns { duplicate, outcome, detail? }. Throws only on a storage failure. */
- function apply(event) {
+ async function apply(event) {
const work = plan(event);
const work = plan(event);
if (typeof work === 'string') return { duplicate: false, outcome: work };
if (typeof work === 'string') return { duplicate: false, outcome: work };
let after = null;
let after = null;
- const r = inbox.once(CONSUMER, event.event_id, () => {
- const out = work();
+ const r = await inbox.once(CONSUMER, event.event_id, async () => {
+ const out = await work();
if (typeof out === 'string') return { outcome: out };
if (typeof out === 'string') return { outcome: out };
after = out.after || null;
after = out.after || null;
return { outcome: out.outcome, detail: out.detail };
return { outcome: out.outcome, detail: out.detail };
});
});
if (r.duplicate) return { duplicate: true, outcome: null };
if (r.duplicate) return { duplicate: true, outcome: null };
// Side effects outside the database run only after the commit, once per event.
// Side effects outside the database run only after the commit, once per event.
- if (after) { try { after(); } catch (err) { log.warn('[Events consumer] after-commit step failed:', err.message); } }
+ if (after) { try { await after(); } catch (err) { log.warn('[Events consumer] after-commit step failed:', err.message); } }
return { duplicate: false, outcome: r.result.outcome, ...(r.result.detail ? { detail: r.result.detail } : {}) };
return { duplicate: false, outcome: r.result.outcome, ...(r.result.detail ? { detail: r.result.detail } : {}) };
}
}
const router = express.Router();
const router = express.Router();
- router.post('/', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
+ router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
const ctx = http.requestContext(req.headers);
const ctx = http.requestContext(req.headers);
const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });
const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });
if (viaProxy(req)) return problem(403, 'chat.internal_only', 'internal route');
if (viaProxy(req)) return problem(403, 'chat.internal_only', 'internal route');
@@ -183,7 +182,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
@@ -183,7 +182,7 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
}
}
let out;
let out;
try {
try {
- out = apply(event);
+ out = await apply(event);
} catch (err) {
} catch (err) {
// Not acknowledged: the inbox claim rolled back with the change, and Events retries.
// Not acknowledged: the inbox claim rolled back with the change, and Events retries.
stats.failed++;
stats.failed++;
@@ -202,13 +201,13 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
@@ -202,13 +201,13 @@ function createEventsConsumer({ chatServer, secrets = [], now = () => Date.now()
});
});
/** Drop inbox receipts older than Events' retention (nothing older can be redelivered). */
/** Drop inbox receipts older than Events' retention (nothing older can be redelivered). */
- function prune() {
- return db.run(`DELETE FROM ${INBOX_TABLE} WHERE processed_at < ?`, [now() - INBOX_KEEP_MS]).changes;
+ async function prune() {
+ return (await db.run(`DELETE FROM ${INBOX_TABLE} WHERE processed_at < ?`, [now() - INBOX_KEEP_MS])).changes;
}
}
let timer = null;
let timer = null;
function start() {
function start() {
if (timer) return;
if (timer) return;
- timer = setInterval(() => { try { prune(); } catch (err) { log.warn('[Events consumer] inbox prune:', err.message); } }, 6 * 3600 * 1000);
+ timer = setInterval(async () => { try { await prune(); } catch (err) { log.warn('[Events consumer] inbox prune:', err.message); } }, 6 * 3600 * 1000);
if (timer.unref) timer.unref();
if (timer.unref) timer.unref();
}
}
function stop() { if (timer) clearInterval(timer); timer = null; }
function stop() { if (timer) clearInterval(timer); timer = null; }
@@ -28,7 +28,7 @@ function actorFor(subjectId) {
@@ -28,7 +28,7 @@ function actorFor(subjectId) {
}
}
/** Add one envelope to the outbox. Call inside the transaction that makes the change. */
/** Add one envelope to the outbox. Call inside the transaction that makes the change. */
-function enqueue({ event_type, subject, payload, visibility = 'internal', priority = 'important', actorSubject = null }) {
+async function enqueue({ event_type, subject, payload, visibility = 'internal', priority = 'important', actorSubject = null }) {
const db = require('../db/database');
const db = require('../db/database');
const ms = Date.now();
const ms = Date.now();
const env = {
const env = {
@@ -45,7 +45,7 @@ function enqueue({ event_type, subject, payload, visibility = 'internal', priori
@@ -45,7 +45,7 @@ function enqueue({ event_type, subject, payload, visibility = 'internal', priori
};
};
const v = validate('events.event-envelope@1', env);
const v = validate('events.event-envelope@1', env);
if (!v.valid) throw new Error(`outbox: invalid envelope for ${event_type}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
if (!v.valid) throw new Error(`outbox: invalid envelope for ${event_type}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
- db.run('INSERT INTO events_outbox (event_id, event_type, event, created_at) VALUES (?, ?, ?, ?)', [env.event_id, event_type, JSON.stringify(env), env.timestamp]);
+ await db.run('INSERT INTO events_outbox (event_id, event_type, event, created_at) VALUES (?, ?, ?, ?)', [env.event_id, event_type, JSON.stringify(env), env.timestamp]);
return env;
return env;
}
}
@@ -63,9 +63,9 @@ function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log =
@@ -63,9 +63,9 @@ function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log =
let timer = null;
let timer = null;
let busy = false;
let busy = false;
- function markFailed(rows, message) {
+ async function markFailed(rows, message) {
const mark = db.getDb().prepare('UPDATE events_outbox SET attempts = attempts + 1, last_error = ? WHERE seq = ?');
const mark = db.getDb().prepare('UPDATE events_outbox SET attempts = attempts + 1, last_error = ? WHERE seq = ?');
- db.transaction(() => { for (const r of rows) mark.run(String(message).slice(0, 500), r.seq); });
+ await db.tx(async () => { for (const r of rows) await mark.run(String(message).slice(0, 500), r.seq); });
log.warn(`[Chat] outbox relay: ${rows.length} event(s) not published: ${message}`);
log.warn(`[Chat] outbox relay: ${rows.length} event(s) not published: ${message}`);
}
}
@@ -76,7 +76,7 @@ function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log =
@@ -76,7 +76,7 @@ function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log =
let sent = 0;
let sent = 0;
try {
try {
for (;;) {
for (;;) {
- const rows = db.all('SELECT seq, event_id, event FROM events_outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100');
+ const rows = await db.all('SELECT seq, event_id, event FROM events_outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100');
if (!rows.length) break;
if (!rows.length) break;
let res;
let res;
try {
try {
@@ -87,18 +87,18 @@ function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log =
@@ -87,18 +87,18 @@ function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log =
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000),
});
});
} catch (e) {
} catch (e) {
- markFailed(rows, e.message);
+ await markFailed(rows, e.message);
break;
break;
}
}
if (res.status === 401) tokens.invalidate();
if (res.status === 401) tokens.invalidate();
if (!res.ok) {
if (!res.ok) {
const text = await res.text().catch(() => '');
const text = await res.text().catch(() => '');
- markFailed(rows, `${res.status} ${text.slice(0, 300)}`);
+ await markFailed(rows, `${res.status} ${text.slice(0, 300)}`);
break;
break;
}
}
const at = new Date().toISOString();
const at = new Date().toISOString();
const mark = db.getDb().prepare('UPDATE events_outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?');
const mark = db.getDb().prepare('UPDATE events_outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?');
- db.transaction(() => { for (const r of rows) mark.run(at, r.seq); });
+ await db.tx(async () => { for (const r of rows) await mark.run(at, r.seq); });
sent += rows.length;
sent += rows.length;
}
}
} finally {
} finally {
@@ -120,11 +120,11 @@ function startAtBoot({ config, port, fetchImpl, log = console, retryMs = BOOT_RE
@@ -120,11 +120,11 @@ function startAtBoot({ config, port, fetchImpl, log = console, retryMs = BOOT_RE
const next = retryMs[i + 1];
const next = retryMs[i + 1];
log.warn(`[Events] subscriptions not ensured: ${err.message}${next != null ? `; retrying in ${Math.round(next / 1000)} s` : '; giving up until the next restart (or run scripts/subscribe-events.js)'}`);
log.warn(`[Events] subscriptions not ensured: ${err.message}${next != null ? `; retrying in ${Math.round(next / 1000)} s` : '; giving up until the next restart (or run scripts/subscribe-events.js)'}`);
if (next == null || stopped) return resolve(null);
if (next == null || stopped) return resolve(null);
- timer = setTimeout(() => attempt(i + 1), next);
+ timer = setTimeout(async () => await attempt(i + 1), next);
if (timer.unref) timer.unref();
if (timer.unref) timer.unref();
}
}
};
};
- timer = setTimeout(() => attempt(0), retryMs[0] || 0);
+ timer = setTimeout(async () => await attempt(0), retryMs[0] || 0);
if (timer.unref) timer.unref();
if (timer.unref) timer.unref();
});
});
return { done, stop() { stopped = true; if (timer) clearTimeout(timer); finish(null); } };
return { done, stop() { stopped = true; if (timer) clearTimeout(timer); finish(null); } };
@@ -27,10 +27,20 @@ const { limits } = require('./net/actor-limits');
@@ -27,10 +27,20 @@ const { limits } = require('./net/actor-limits');
const { createValkey } = require('openvibe-sdk/valkey');
const { createValkey } = require('openvibe-sdk/valkey');
const { createApp } = require('./app');
const { createApp } = require('./app');
+let rejectionsLogged = false;
+
async function start() {
async function start() {
- console.log(`[Chat] OpenVibe.Chat starting (db ${config.dbPath}, Live ${config.live.internalUrl}, mirror ${config.live.mirror ? 'on' : 'off'}, events ${config.events.url || 'off'}, consumer ${config.events.secrets.length ? 'on' : 'off'})`);
- // Mirror capture only when the mirror is on: before the cutover (rehearsals) nothing queues.
- db.initDb({ captureMirror: config.live.mirror });
+ // Background loops (timers, socket close handlers) await the database now; a transient failure there (a
+ // PgBouncer restart, a statement timeout) is logged, and the loop's next tick tries again, instead of ending
+ // the process as an unhandled rejection would.
+ if (!rejectionsLogged) {
+ rejectionsLogged = true;
+ process.on('unhandledRejection', (err) => console.error('[Chat] unhandled rejection:', (err && err.stack) || err));
+ }
+ console.log(`[Chat] OpenVibe.Chat starting (db ${config.db.url ? 'postgresql' : 'pglite'}, Live ${config.live.internalUrl}, mirror ${config.live.mirror ? 'on' : 'off'}, events ${config.events.url || 'off'}, consumer ${config.events.secrets.length ? 'on' : 'off'})`);
+ // PostgreSQL (DATABASE_URL; migrations first, as the owner) or, outside production, an embedded PGlite.
+ // The Live mirror's capture is the migration's triggers; with the mirror off its queue is emptied.
+ await db.initDb();
// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this
// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this
// process, as before. openvibe-sdk/valkey's createValkey returns null when the URL is unset.
// process, as before. openvibe-sdk/valkey's createValkey returns null when the URL is unset.
@@ -69,7 +79,7 @@ async function start() {
@@ -69,7 +79,7 @@ async function start() {
stopping = true;
stopping = true;
console.log('[Chat] shutting down');
console.log('[Chat] shutting down');
try {
try {
- chatServer.broadcastAll({
+ await chatServer.broadcastAll({
type: 'server_restart',
type: 'server_restart',
message: '⚙️ Chat server restarting — you will be reconnected automatically.',
message: '⚙️ Chat server restarting — you will be reconnected automatically.',
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(),
@@ -84,8 +94,8 @@ async function start() {
@@ -84,8 +94,8 @@ async function start() {
try { if (valkey) await valkey.close(); } catch { /* */ }
try { if (valkey) await valkey.close(); } catch { /* */ }
try { await Promise.race([mirror.flush(), new Promise((r) => setTimeout(r, 3000))]); } catch { /* */ }
try { await Promise.race([mirror.flush(), new Promise((r) => setTimeout(r, 3000))]); } catch { /* */ }
try { chatServer.close(); } catch { /* */ }
try { chatServer.close(); } catch { /* */ }
- try { callServer.close(); } catch { /* */ }
- server.close(() => { db.close(); process.exit(0); });
+ try { await callServer.close(); } catch { /* */ }
+ server.close(async () => { await db.close(); process.exit(0); });
setTimeout(() => process.exit(0), 5000).unref();
setTimeout(() => process.exit(0), 5000).unref();
};
};
process.on('SIGTERM', shutdown);
process.on('SIGTERM', shutdown);
@@ -149,7 +149,7 @@ async function call(method, path, body, scope, retried = false) {
@@ -149,7 +149,7 @@ async function call(method, path, body, scope, retried = false) {
stats.failures++; stats.lastError = err.message;
stats.failures++; stats.lastError = err.message;
throw new LiveError(0, `Live unreachable: ${err.message}`);
throw new LiveError(0, `Live unreachable: ${err.message}`);
}
}
- if (res.status === 401 && !retried) { serviceAuth.invalidate(config.live.audience, scope); return call(method, path, body, scope, true); }
+ if (res.status === 401 && !retried) { serviceAuth.invalidate(config.live.audience, scope); return await call(method, path, body, scope, true); }
if (res.status === 304) return { notModified: true };
if (res.status === 304) return { notModified: true };
const data = await res.json().catch(() => null);
const data = await res.json().catch(() => null);
if (!res.ok) {
if (!res.ok) {
@@ -158,8 +158,8 @@ async function call(method, path, body, scope, retried = false) {
@@ -158,8 +158,8 @@ async function call(method, path, body, scope, retried = false) {
}
}
return data;
return data;
}
}
-const read = (path, body) => { stats.reads++; return call(body === undefined ? 'GET' : 'POST', `/internal/chat-context${path}`, body, SCOPE_READ); };
-const effect = (name, body) => { stats.effects++; return call('POST', `/internal/chat-effects/${name}`, body || {}, SCOPE_WRITE); };
+const read = async (path, body) => { stats.reads++; return await call(body === undefined ? 'GET' : 'POST', `/internal/chat-context${path}`, body, SCOPE_READ); };
+const effect = async (name, body) => { stats.effects++; return await call('POST', `/internal/chat-effects/${name}`, body || {}, SCOPE_WRITE); };
/** Fire-and-forget effect: logged, never thrown. */
/** Fire-and-forget effect: logged, never thrown. */
function fire(name, body) { effect(name, body).catch((err) => console.warn(`[LiveContext] ${name}: ${err.message}`)); }
function fire(name, body) { effect(name, body).catch((err) => console.warn(`[LiveContext] ${name}: ${err.message}`)); }
@@ -221,50 +221,51 @@ function emoteSources(raw) {
@@ -221,50 +221,51 @@ function emoteSources(raw) {
return out;
return out;
}
}
-function upsertRows(table, cols, rows) {
+async function upsertRows(table, cols, rows) {
if (!rows || !rows.length) return 0;
if (!rows || !rows.length) return 0;
const sql = `INSERT INTO ${table} (${cols.join(', ')}, synced_at) VALUES (${cols.map(() => '?').join(', ')}, ?)
const sql = `INSERT INTO ${table} (${cols.join(', ')}, synced_at) VALUES (${cols.map(() => '?').join(', ')}, ?)
ON CONFLICT(id) DO UPDATE SET ${cols.filter((c) => c !== 'id').map((c) => `${c} = excluded.${c}`).join(', ')}, synced_at = excluded.synced_at`;
ON CONFLICT(id) DO UPDATE SET ${cols.filter((c) => c !== 'id').map((c) => `${c} = excluded.${c}`).join(', ')}, synced_at = excluded.synced_at`;
const st = db.getDb().prepare(sql);
const st = db.getDb().prepare(sql);
const now = Date.now();
const now = Date.now();
- db.transaction(() => {
+ await db.tx(async () => {
for (const r of rows) {
for (const r of rows) {
if (!r || r.id == null) continue;
if (!r || r.id == null) continue;
- st.run(...cols.map((c) => (r[c] === undefined ? null : (typeof r[c] === 'boolean' ? (r[c] ? 1 : 0) : r[c]))), now);
+ await st.run(...cols.map((c) => (r[c] === undefined ? null : (typeof r[c] === 'boolean' ? (r[c] ? 1 : 0) : r[c]))), now);
}
}
});
});
return rows.length;
return rows.length;
}
}
/** Store a user as Live sent it (only the projection columns are kept). */
/** Store a user as Live sent it (only the projection columns are kept). */
-function upsertUser(row) {
+async function upsertUser(row) {
if (!row || row.id == null) return;
if (!row || row.id == null) return;
- upsertRows('ctx_users', USER_COLS, [row]);
+ await upsertRows('ctx_users', USER_COLS, [row]);
}
}
const userSelect = 'SELECT * FROM ctx_users';
const userSelect = 'SELECT * FROM ctx_users';
-function getUserById(id) { if (!id) return null; const u = db.get(`${userSelect} WHERE id = ?`, [id]) || null; if (!u) ensureUsers([id]).catch(() => {}); return u; }
-function getUserByUsername(name) { return name ? (db.get(`${userSelect} WHERE username = ? COLLATE NOCASE`, [String(name)]) || null) : null; }
-function getUserByDisplayName(name) { return name ? (db.get(`${userSelect} WHERE display_name = ? COLLATE NOCASE`, [String(name)]) || null) : null; }
-function subjectFor(userId) { return db.subjectFor(userId); }
+async function getUserById(id) { if (!id) return null; const u = await db.get(`${userSelect} WHERE id = ?`, [id]) || null; if (!u) ensureUsers([id]).catch(() => {}); return u; }
+async function getUserByUsername(name) { return name ? (await db.get(`${userSelect} WHERE lower(username) = lower(?)`, [String(name)]) || null) : null; }
+async function getUserByDisplayName(name) { return name ? (await db.get(`${userSelect} WHERE lower(display_name) = lower(?)`, [String(name)]) || null) : null; }
+async function subjectFor(userId) { return await db.subjectFor(userId); }
/** Fetch users the projection lacks. */
/** Fetch users the projection lacks. */
async function ensureUsers(ids) {
async function ensureUsers(ids) {
const want = [...new Set((ids || []).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
const want = [...new Set((ids || []).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
- const missing = want.filter((id) => !db.get('SELECT 1 FROM ctx_users WHERE id = ?', [id]));
+ const have = new Set((await db.all('SELECT id FROM ctx_users WHERE id = ANY(?)', [want])).map((r) => Number(r.id)));
+ const missing = want.filter((id) => !have.has(id));
if (!missing.length) return 0;
if (!missing.length) return 0;
const data = await read('/users/lookup', { ids: missing.slice(0, 500) });
const data = await read('/users/lookup', { ids: missing.slice(0, 500) });
- return upsertRows('ctx_users', USER_COLS, data.users || []);
+ return await upsertRows('ctx_users', USER_COLS, data.users || []);
}
}
/** Look a user up by login (then display name) in the projection, asking Live on a miss. */
/** Look a user up by login (then display name) in the projection, asking Live on a miss. */
async function ensureUserByUsername(name) {
async function ensureUserByUsername(name) {
- const local = getUserByUsername(name);
+ const local = await getUserByUsername(name);
if (local) return local;
if (local) return local;
try {
try {
const data = await read('/users/lookup', { usernames: [String(name)] });
const data = await read('/users/lookup', { usernames: [String(name)] });
- upsertRows('ctx_users', USER_COLS, data.users || []);
+ await upsertRows('ctx_users', USER_COLS, data.users || []);
} catch (err) { console.warn('[LiveContext] user lookup:', err.message); }
} catch (err) { console.warn('[LiveContext] user lookup:', err.message); }
- return getUserByUsername(name);
+ return await getUserByUsername(name);
}
}
const STREAM_SELECT = `SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color,
const STREAM_SELECT = `SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color,
@@ -273,20 +274,20 @@ const STREAM_SELECT = `SELECT s.*, u.username, u.display_name, u.avatar_url, u.p
@@ -273,20 +274,20 @@ const STREAM_SELECT = `SELECT s.*, u.username, u.display_name, u.avatar_url, u.p
LEFT JOIN ctx_users u ON s.user_id = u.id
LEFT JOIN ctx_users u ON s.user_id = u.id
LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id`;
LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id`;
-function getStreamById(id) {
+async function getStreamById(id) {
const sid = parseInt(id, 10);
const sid = parseInt(id, 10);
if (!sid) return null;
if (!sid) return null;
- const row = db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;
+ const row = await db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;
if (!row) ensureStream(sid).catch(() => {});
if (!row) ensureStream(sid).catch(() => {});
return row;
return row;
}
}
const _streamFetch = new Swr(TTL.streamRow, async (sid) => {
const _streamFetch = new Swr(TTL.streamRow, async (sid) => {
const data = await read(`/streams/${sid}`);
const data = await read(`/streams/${sid}`);
if (data.stream) {
if (data.stream) {
- upsertRows('ctx_streams', STREAM_COLS, [data.stream]);
- if (data.owner) upsertUser(data.owner);
- if (data.managed_stream) upsertRows('ctx_managed_streams', MS_COLS, [data.managed_stream]);
- if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
+ await upsertRows('ctx_streams', STREAM_COLS, [data.stream]);
+ if (data.owner) await upsertUser(data.owner);
+ if (data.managed_stream) await upsertRows('ctx_managed_streams', MS_COLS, [data.managed_stream]);
+ if (data.channel) await upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
}
}
return !!data.stream;
return !!data.stream;
});
});
@@ -294,8 +295,8 @@ const _streamFetch = new Swr(TTL.streamRow, async (sid) => {
@@ -294,8 +295,8 @@ const _streamFetch = new Swr(TTL.streamRow, async (sid) => {
async function ensureStream(id) {
async function ensureStream(id) {
const sid = parseInt(id, 10);
const sid = parseInt(id, 10);
if (!sid) return null;
if (!sid) return null;
- if (!db.get('SELECT 1 FROM ctx_streams WHERE id = ?', [sid])) await _streamFetch.ensure(sid);
- return db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;
+ if (!await db.get('SELECT 1 FROM ctx_streams WHERE id = ?', [sid])) await _streamFetch.ensure(sid);
+ return await db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;
}
}
/**
/**
* Read one stream from Live now, past the cache (a call hook or an ownership check wants its live
* Read one stream from Live now, past the cache (a call hook or an ownership check wants its live
@@ -305,20 +306,20 @@ async function refreshStream(id) {
@@ -305,20 +306,20 @@ async function refreshStream(id) {
const sid = parseInt(id, 10);
const sid = parseInt(id, 10);
if (!sid) return null;
if (!sid) return null;
try { await _streamFetch.refresh(sid); } catch (err) { console.warn(`[LiveContext] stream ${sid}: ${err.message}`); }
try { await _streamFetch.refresh(sid); } catch (err) { console.warn(`[LiveContext] stream ${sid}: ${err.message}`); }
- return db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;
+ return await db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;
}
}
-function latestStreamIdForUser(userId) {
+async function latestStreamIdForUser(userId) {
if (!userId) return null;
if (!userId) return null;
- return db.get('SELECT id FROM ctx_streams WHERE user_id = ? ORDER BY id DESC LIMIT 1', [userId])?.id || null;
+ return (await db.get('SELECT id FROM ctx_streams WHERE user_id = ? ORDER BY id DESC LIMIT 1', [userId]))?.id || null;
}
}
-function getLiveStreamsByUserId(userId) {
- return db.all(`${STREAM_SELECT} WHERE s.user_id = ? AND s.is_live = 1 ORDER BY s.started_at DESC`, [userId]);
+async function getLiveStreamsByUserId(userId) {
+ return await db.all(`${STREAM_SELECT} WHERE s.user_id = ? AND s.is_live = 1 ORDER BY s.started_at DESC`, [userId]);
}
}
-function getStreamsByUserId(userId, limit = 50) {
- return db.all(`${STREAM_SELECT} WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?`, [userId, limit]);
+async function getStreamsByUserId(userId, limit = 50) {
+ return await db.all(`${STREAM_SELECT} WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?`, [userId, limit]);
}
}
-function getManagedStreamsByUserId(userId) {
- return db.all(`
+async function getManagedStreamsByUserId(userId) {
+ return await db.all(`
SELECT ms.*,
SELECT ms.*,
(SELECT s.is_live FROM ctx_streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS is_currently_live,
(SELECT s.is_live FROM ctx_streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS is_currently_live,
(SELECT s.id FROM ctx_streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS live_session_id
(SELECT s.id FROM ctx_streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS live_session_id
@@ -327,30 +328,30 @@ function getManagedStreamsByUserId(userId) {
@@ -327,30 +328,30 @@ function getManagedStreamsByUserId(userId) {
ORDER BY ms.sort_order ASC, ms.created_at ASC`, [userId]);
ORDER BY ms.sort_order ASC, ms.created_at ASC`, [userId]);
}
}
-function getChannelById(id) { return id ? (db.get('SELECT * FROM ctx_channels WHERE id = ?', [id]) || null) : null; }
-function getChannelByUserId(userId) { return userId ? (db.get('SELECT * FROM ctx_channels WHERE user_id = ?', [userId]) || null) : null; }
+async function getChannelById(id) { return id ? (await db.get('SELECT * FROM ctx_channels WHERE id = ?', [id]) || null) : null; }
+async function getChannelByUserId(userId) { return userId ? (await db.get('SELECT * FROM ctx_channels WHERE user_id = ?', [userId]) || null) : null; }
/** The channel row of a user, asking Live when the projection lacks it (null = the user has none). */
/** The channel row of a user, asking Live when the projection lacks it (null = the user has none). */
async function ensureChannelForUser(userId) {
async function ensureChannelForUser(userId) {
- const local = getChannelByUserId(userId);
+ const local = await getChannelByUserId(userId);
if (local || !userId) return local;
if (local || !userId) return local;
try {
try {
const data = await read(`/channels/by-user/${parseInt(userId, 10)}`);
const data = await read(`/channels/by-user/${parseInt(userId, 10)}`);
- if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
+ if (data.channel) await upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
} catch (err) { console.warn('[LiveContext] channel lookup:', err.message); }
} catch (err) { console.warn('[LiveContext] channel lookup:', err.message); }
- return getChannelByUserId(userId);
+ return await getChannelByUserId(userId);
}
}
/** Live's ensureChannel(userId): create the channel if the user has none (effect). */
/** Live's ensureChannel(userId): create the channel if the user has none (effect). */
async function createChannel(userId) {
async function createChannel(userId) {
const data = await effect('ensure-channel', { user_id: userId });
const data = await effect('ensure-channel', { user_id: userId });
- if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
- return getChannelByUserId(userId);
+ if (data.channel) await upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
+ return await getChannelByUserId(userId);
}
}
/** Write the channel's emote source switches (Live's channels.emote_sources) and refresh the cache. */
/** Write the channel's emote source switches (Live's channels.emote_sources) and refresh the cache. */
async function setChannelEmoteSources(userId, sources) {
async function setChannelEmoteSources(userId, sources) {
const clean = emoteSources(sources);
const clean = emoteSources(sources);
await effect('channel-emote-sources', { user_id: userId, sources: clean });
await effect('channel-emote-sources', { user_id: userId, sources: clean });
- db.run('UPDATE ctx_channels SET emote_sources = ? WHERE user_id = ?', [JSON.stringify(clean), Number(userId)]);
+ await db.run('UPDATE ctx_channels SET emote_sources = ? WHERE user_id = ?', [JSON.stringify(clean), Number(userId)]);
return clean;
return clean;
}
}
@@ -371,38 +372,38 @@ function _emitSettings(channelId, settings) {
@@ -371,38 +372,38 @@ function _emitSettings(channelId, settings) {
// moderation settings and moderator ids are Chat's own rows now, read in place.
// moderation settings and moderator ids are Chat's own rows now, read in place.
const _policy = new Swr(TTL.policy, async (channelId) => {
const _policy = new Swr(TTL.policy, async (channelId) => {
const data = await read(`/channels/${channelId}/policy`);
const data = await read(`/channels/${channelId}/policy`);
- if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
+ if (data.channel) await upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
// The moderation settings are Chat's own rows: a fresh channel read refreshes the listeners'
// The moderation settings are Chat's own rows: a fresh channel read refreshes the listeners'
// baseline (the chat server records what the room was last told; the first read announces nothing).
// baseline (the chat server records what the room was last told; the first read announces nothing).
- _emitSettings(channelId, getChannelModerationSettings(channelId));
+ _emitSettings(channelId, await getChannelModerationSettings(channelId));
return { language: data.language || 'en' };
return { language: data.language || 'en' };
});
});
function _policyFor(channelId) { return channelId ? _policy.peek(Number(channelId)) : undefined; }
function _policyFor(channelId) { return channelId ? _policy.peek(Number(channelId)) : undefined; }
// Chat is the only writer of these tables (C-04 done): an indexed local row, no network, no cache.
// Chat is the only writer of these tables (C-04 done): an indexed local row, no network, no cache.
-function getChannelModerationSettings(channelId) {
- return (channelId && db.get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)])) || defaultModerationSettings(channelId);
+async function getChannelModerationSettings(channelId) {
+ return (channelId && await db.get('SELECT * FROM channel_moderation_settings WHERE channel_id = ?', [Number(channelId)])) || defaultModerationSettings(channelId);
}
}
-function isChannelModerator(userId, channelId) {
- return !!(userId && channelId && db.get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [Number(userId), Number(channelId)]));
+async function isChannelModerator(userId, channelId) {
+ return !!(userId && channelId && await db.get('SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?', [Number(userId), Number(channelId)]));
}
}
-function channelLanguage(channelUserId) {
- const ch = getChannelByUserId(channelUserId);
+async function channelLanguage(channelUserId) {
+ const ch = await getChannelByUserId(channelUserId);
if (!ch) return 'en';
if (!ch) return 'en';
const p = _policyFor(ch.id);
const p = _policyFor(ch.id);
return (p && p.language) || 'en';
return (p && p.language) || 'en';
}
}
-function getChannelAlertSoundsByUser(userId) {
- const ch = getChannelByUserId(userId);
+async function getChannelAlertSoundsByUser(userId) {
+ const ch = await getChannelByUserId(userId);
if (!ch) return {};
if (!ch) return {};
- const s = getChannelModerationSettings(ch.id);
+ const s = await getChannelModerationSettings(ch.id);
const pick = { donation_sound_url: s.donation_sound_url || null, donation_sound_mime: s.donation_sound_mime || null, goal_sound_url: s.goal_sound_url || null, goal_sound_mime: s.goal_sound_mime || null };
const pick = { donation_sound_url: s.donation_sound_url || null, donation_sound_mime: s.donation_sound_mime || null, goal_sound_url: s.goal_sound_url || null, goal_sound_mime: s.goal_sound_mime || null };
return pick;
return pick;
}
}
function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(channelId)) : Promise.resolve(); }
function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(channelId)) : Promise.resolve(); }
-function invalidateChannel(channelId) {
+async function invalidateChannel(channelId) {
if (!channelId) return;
if (!channelId) return;
_policy.invalidate(Number(channelId));
_policy.invalidate(Number(channelId));
- _emitSettings(channelId, getChannelModerationSettings(channelId));
+ _emitSettings(channelId, await getChannelModerationSettings(channelId));
}
}
/**
/**
* A fresh policy read that starts after the call (Chat just had Live write a setting): a load already
* A fresh policy read that starts after the call (Chat just had Live write a setting): a load already
@@ -421,7 +422,7 @@ let _bans = { rows: [], version: null, at: 0, cidr: [] };
@@ -421,7 +422,7 @@ let _bans = { rows: [], version: null, at: 0, cidr: [] };
let _bansLoading = null;
let _bansLoading = null;
// A ban ends at expires_at, read as a UTC instant: /timeout stores an ISO string ('…T…Z'), other
// A ban ends at expires_at, read as a UTC instant: /timeout stores an ISO string ('…T…Z'), other
-// writers SQLite's 'YYYY-MM-DD HH:MM:SS'. Live compares the TEXT with CURRENT_TIMESTAMP, where
+// writers SQLite's 'YYYY-MM-DD HH:MM:SS'. Live compares the TEXT with ov_now(), where
// 'T' sorts after ' ', so a 60-second timeout lasted until the end of that UTC day; Chat, which
// 'T' sorts after ' ', so a 60-second timeout lasted until the end of that UTC day; Chat, which
// enforces chat bans, ends it on time (parity script, roadmap WS-I task 6). A value that is not a
// enforces chat bans, ends it on time (parity script, roadmap WS-I task 6). A value that is not a
// date keeps Live's TEXT comparison.
// date keeps Live's TEXT comparison.
@@ -475,9 +476,9 @@ async function refreshBans() {
@@ -475,9 +476,9 @@ async function refreshBans() {
function _bansFresh() { if (Date.now() - _bans.at > TTL.bans) refreshBans().catch(() => {}); return _bans; }
function _bansFresh() { if (Date.now() - _bans.at > TTL.bans) refreshBans().catch(() => {}); return _bans; }
// A fresh read that starts after the call: a load already in flight may have left before a ban
// A fresh read that starts after the call: a load already in flight may have left before a ban
// was written, so it is waited for and followed by another.
// was written, so it is waited for and followed by another.
-function invalidateBans() {
- const reload = () => { _bans.version = null; _bans.at = 0; return refreshBans(); };
- return _bansLoading ? _bansLoading.then(reload) : reload();
+async function invalidateBans() {
+ const reload = async () => { _bans.version = null; _bans.at = 0; return await refreshBans(); };
+ return _bansLoading ? _bansLoading.then(reload) : await reload();
}
}
function isUserBanned(userId, streamId) {
function isUserBanned(userId, streamId) {
@@ -676,9 +677,9 @@ async function authenticate(token) {
@@ -676,9 +677,9 @@ async function authenticate(token) {
const hit = _auth.get(key);
const hit = _auth.get(key);
if (hit && hit.until > now) return hit.user ? { ...hit.user } : null;
if (hit && hit.until > now) return hit.user ? { ...hit.user } : null;
if (_authPending.has(key)) return _authPending.get(key).then((u) => (u ? { ...u } : null));
if (_authPending.has(key)) return _authPending.get(key).then((u) => (u ? { ...u } : null));
- const p = read('/auth', { token }).then((data) => {
+ const p = read('/auth', { token }).then(async (data) => {
const user = data && data.user ? data.user : null;
const user = data && data.user ? data.user : null;
- if (user) upsertUser(user);
+ if (user) await upsertUser(user);
const exp = data && data.expires_at ? Date.parse(data.expires_at) : null;
const exp = data && data.expires_at ? Date.parse(data.expires_at) : null;
const ttl = user ? TTL.auth : TTL.authNegative;
const ttl = user ? TTL.auth : TTL.authNegative;
const until = Math.min(now + ttl, exp && Number.isFinite(exp) ? exp : Infinity);
const until = Math.min(now + ttl, exp && Number.isFinite(exp) ? exp : Infinity);
@@ -722,18 +723,18 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {
@@ -722,18 +723,18 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {
const stream = streamId ? await ensureStream(streamId) : null;
const stream = streamId ? await ensureStream(streamId) : null;
const ownerId = (stream && stream.user_id) || channelUserId || null;
const ownerId = (stream && stream.user_id) || channelUserId || null;
const tasks = [ensureSettings()];
const tasks = [ensureSettings()];
- let channel = stream && stream.channel_id ? getChannelById(stream.channel_id) : null;
+ let channel = stream && stream.channel_id ? await getChannelById(stream.channel_id) : null;
if (!channel && ownerId) channel = await ensureChannelForUser(ownerId);
if (!channel && ownerId) channel = await ensureChannelForUser(ownerId);
if (channel) tasks.push(ensurePolicy(channel.id));
if (channel) tasks.push(ensurePolicy(channel.id));
// Live's chat server asks canModerateChannel(user, channelUserId) — the channel OWNER's user
// Live's chat server asks canModerateChannel(user, channelUserId) — the channel OWNER's user
// id where a channel id belongs — for the AI-viewer / PowerChat "is mod" flag. Kept as it
// id where a channel id belongs — for the AI-viewer / PowerChat "is mod" flag. Kept as it
// was; that lookup is warmed too so a message never waits on it.
// was; that lookup is warmed too so a message never waits on it.
if (ownerId && (!channel || channel.id !== ownerId)) tasks.push(ensurePolicy(ownerId));
if (ownerId && (!channel || channel.id !== ownerId)) tasks.push(ensurePolicy(ownerId));
- if (user && user.id) { tasks.push(_follows.ensure(Number(user.id))); tasks.push(ensureDecor([user.id])); if (ip) tasks.push(ensureAnonFirstSeen(ip)); }
+ if (user && user.id) { tasks.push(_follows.ensure(Number(user.id))); tasks.push(await ensureDecor([user.id])); if (ip) tasks.push(ensureAnonFirstSeen(ip)); }
await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));
await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));
- if (channel && getChannelModerationSettings(channel.id).ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});
+ if (channel && (await getChannelModerationSettings(channel.id)).ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});
// Sub-only room: the viewer's subscription answer, so their first line does not wait for it.
// Sub-only room: the viewer's subscription answer, so their first line does not wait for it.
- if (channel && user && user.id && ownerId && Number(ownerId) !== Number(user.id) && getChannelModerationSettings(channel.id).sub_only) {
+ if (channel && user && user.id && ownerId && Number(ownerId) !== Number(user.id) && (await getChannelModerationSettings(channel.id)).sub_only) {
await ensureSubscriber(user.id, ownerId).catch(() => {});
await ensureSubscriber(user.id, ownerId).catch(() => {});
}
}
})();
})();
@@ -743,12 +744,12 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {
@@ -743,12 +744,12 @@ async function warm({ user, streamId, channelUserId, ip } = {}) {
// ── Projection sync ─────────────────────────────────────────────────────────────────────────
// ── Projection sync ─────────────────────────────────────────────────────────────────────────
async function syncTable(kind, table, cols, { full = false } = {}) {
async function syncTable(kind, table, cols, { full = false } = {}) {
- let after = full ? 0 : (db.get(`SELECT COALESCE(MAX(id), 0) AS m FROM ${table}`)?.m || 0);
+ let after = full ? 0 : ((await db.get(`SELECT COALESCE(MAX(id), 0) AS m FROM ${table}`))?.m || 0);
let total = 0;
let total = 0;
for (let pages = 0; pages < 10000; pages++) {
for (let pages = 0; pages < 10000; pages++) {
const data = await read(`/${kind}?after_id=${after}&limit=${PAGE}`);
const data = await read(`/${kind}?after_id=${after}&limit=${PAGE}`);
const rows = data.rows || [];
const rows = data.rows || [];
- total += upsertRows(table, cols, rows);
+ total += await upsertRows(table, cols, rows);
if (rows.length < PAGE) break;
if (rows.length < PAGE) break;
after = rows[rows.length - 1].id;
after = rows[rows.length - 1].id;
}
}
@@ -756,24 +757,24 @@ async function syncTable(kind, table, cols, { full = false } = {}) {
@@ -756,24 +757,24 @@ async function syncTable(kind, table, cols, { full = false } = {}) {
}
}
async function syncActiveStreams() {
async function syncActiveStreams() {
const data = await read('/streams/active');
const data = await read('/streams/active');
- upsertRows('ctx_streams', STREAM_COLS, data.rows || []);
+ await upsertRows('ctx_streams', STREAM_COLS, data.rows || []);
// Streams that were live here but are not in Live's active list any more ended between
// Streams that were live here but are not in Live's active list any more ended between
// polls (and were not "recently ended" either): mark them offline.
// polls (and were not "recently ended" either): mark them offline.
const liveNow = new Set((data.rows || []).filter((r) => r.is_live).map((r) => Number(r.id)));
const liveNow = new Set((data.rows || []).filter((r) => r.is_live).map((r) => Number(r.id)));
- for (const r of db.all('SELECT id FROM ctx_streams WHERE is_live = 1')) {
- if (!liveNow.has(Number(r.id))) db.run('UPDATE ctx_streams SET is_live = 0 WHERE id = ?', [r.id]);
+ for (const r of await db.all('SELECT id FROM ctx_streams WHERE is_live = 1')) {
+ if (!liveNow.has(Number(r.id))) await db.run('UPDATE ctx_streams SET is_live = 0 WHERE id = ?', [r.id]);
}
}
}
}
const SCHEDULE = [
const SCHEDULE = [
// [name, every ms, fn]
// [name, every ms, fn]
- ['users+', 30_000, () => syncTable('users', 'ctx_users', USER_COLS)],
- ['users*', 15 * 60_000, () => syncTable('users', 'ctx_users', USER_COLS, { full: true })],
- ['streams+', 10_000, () => syncTable('streams', 'ctx_streams', STREAM_COLS)],
+ ['users+', 30_000, async () => await syncTable('users', 'ctx_users', USER_COLS)],
+ ['users*', 15 * 60_000, async () => await syncTable('users', 'ctx_users', USER_COLS, { full: true })],
+ ['streams+', 10_000, async () => await syncTable('streams', 'ctx_streams', STREAM_COLS)],
['streams~', 10_000, syncActiveStreams],
['streams~', 10_000, syncActiveStreams],
- ['streams*', 30 * 60_000, () => syncTable('streams', 'ctx_streams', STREAM_COLS, { full: true })],
- ['managed*', 5 * 60_000, () => syncTable('managed-streams', 'ctx_managed_streams', MS_COLS, { full: true })],
- ['channels*', 5 * 60_000, () => syncTable('channels', 'ctx_channels', CHANNEL_COLS, { full: true })],
+ ['streams*', 30 * 60_000, async () => await syncTable('streams', 'ctx_streams', STREAM_COLS, { full: true })],
+ ['managed*', 5 * 60_000, async () => await syncTable('managed-streams', 'ctx_managed_streams', MS_COLS, { full: true })],
+ ['channels*', 5 * 60_000, async () => await syncTable('channels', 'ctx_channels', CHANNEL_COLS, { full: true })],
// refreshBans and ensureSettings keep the cached value on a Live error instead of throwing
// refreshBans and ensureSettings keep the cached value on a Live error instead of throwing
// (callers want the stale value); for the schedule a refresh that did not land is a failure.
// (callers want the stale value); for the schedule a refresh that did not land is a failure.
['bans', TTL.bans, async () => { const t0 = Date.now(); await refreshBans(); if (_bans.at < t0) throw new Error('bans not refreshed'); }],
['bans', TTL.bans, async () => { const t0 = Date.now(); await refreshBans(); if (_bans.at < t0) throw new Error('bans not refreshed'); }],
@@ -882,7 +883,7 @@ const effects = {
@@ -882,7 +883,7 @@ const effects = {
logIp(entry) { if (entry && entry.ip && entry.ip !== 'unknown') { _ipLog.push(entry); if (_ipLog.length > 5000) _ipLog.splice(0, _ipLog.length - 5000); } },
logIp(entry) { if (entry && entry.ip && entry.ip !== 'unknown') { _ipLog.push(entry); if (_ipLog.length > 5000) _ipLog.splice(0, _ipLog.length - 5000); } },
viewerCount(streamId, count) { if (streamId) _viewerCounts.set(String(streamId), count); },
viewerCount(streamId, count) { if (streamId) _viewerCounts.set(String(streamId), count); },
viewerSnapshot(streamId, count, chatActivity) { _snapshots.push({ stream_id: streamId, viewer_count: count, chat_messages_5m: chatActivity }); },
viewerSnapshot(streamId, count, chatActivity) { _snapshots.push({ stream_id: streamId, viewer_count: count, chat_messages_5m: chatActivity }); },
- setUserColor: (userId, color) => effect('user-color', { user_id: userId, color }).then((r) => { db.run('UPDATE ctx_users SET profile_color = ? WHERE id = ?', [color, userId]); return r; }),
+ setUserColor: (userId, color) => effect('user-color', { user_id: userId, color }).then(async (r) => { await db.run('UPDATE ctx_users SET profile_color = ? WHERE id = ?', [color, userId]); return r; }),
// The ban is in Chat's cache before the moderator is answered (and before their next check).
// The ban is in Chat's cache before the moderator is answered (and before their next check).
ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),
ban: (body) => effect('ban', body).then((r) => invalidateBans().then(() => r)),
// /slow and alert sounds: Chat writes channel_moderation_settings itself (C-04 done; the callers
// /slow and alert sounds: Chat writes channel_moderation_settings itself (C-04 done; the callers
@@ -892,13 +893,13 @@ const effects = {
@@ -892,13 +893,13 @@ const effects = {
if (fields && fields.slow_mode_seconds !== undefined) f.slow_mode_seconds = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);
if (fields && fields.slow_mode_seconds !== undefined) f.slow_mode_seconds = Math.max(0, parseInt(fields.slow_mode_seconds, 10) || 0);
if (fields && fields.sub_only !== undefined) f.sub_only = fields.sub_only ? 1 : 0;
if (fields && fields.sub_only !== undefined) f.sub_only = fields.sub_only ? 1 : 0;
if (!channelId || !Object.keys(f).length) return Promise.reject(new LiveError(400, 'no chat-settable fields'));
if (!channelId || !Object.keys(f).length) return Promise.reject(new LiveError(400, 'no chat-settable fields'));
- return Promise.resolve().then(() => {
- db.upsertChannelModerationSettings(Number(channelId), f);
+ return Promise.resolve().then(async () => {
+ await db.upsertChannelModerationSettings(Number(channelId), f);
return { ok: true };
return { ok: true };
});
});
},
},
setChannelAlertSound: (channelId, kind, url, mime, actorUserId) => {
setChannelAlertSound: (channelId, kind, url, mime, actorUserId) => {
- return Promise.resolve().then(() => {
+ return Promise.resolve().then(async () => {
const file = url ? require('path').resolve(String(url)) : null;
const file = url ? require('path').resolve(String(url)) : null;
if (file) {
if (file) {
// Live's rule: alert sounds are files in the shared sounds directory.
// Live's rule: alert sounds are files in the shared sounds directory.
@@ -907,16 +908,16 @@ const effects = {
@@ -907,16 +908,16 @@ const effects = {
try { inside = require('path').dirname(fs.realpathSync(file)) === fs.realpathSync(require('path').resolve(config.sounds.path)); } catch { inside = false; }
try { inside = require('path').dirname(fs.realpathSync(file)) === fs.realpathSync(require('path').resolve(config.sounds.path)); } catch { inside = false; }
if (!inside) throw new LiveError(400, 'alert sounds live in the sounds directory');
if (!inside) throw new LiveError(400, 'alert sounds live in the sounds directory');
}
}
- db.setChannelAlertSound(Number(channelId), kind === 'goal' ? 'goal' : 'donation', file, file ? String(mime || 'audio/mpeg') : null);
+ await db.setChannelAlertSound(Number(channelId), kind === 'goal' ? 'goal' : 'donation', file, file ? String(mime || 'audio/mpeg') : null);
return { ok: true };
return { ok: true };
});
});
},
},
// One call per real chat line: coins chat bonus, AI viewers, PowerChat relay (Live decides each).
// One call per real chat line: coins chat bonus, AI viewers, PowerChat relay (Live decides each).
- chatMessage: (body) => effect('chat-message', body),
+ chatMessage: async (body) => await effect('chat-message', body),
aiModCommand: (channelUserId, streamId, args, opts) => effect('ai/mod-command', { channel_user_id: channelUserId, stream_id: streamId, args, by: opts && opts.by }).then((r) => r.reply),
aiModCommand: (channelUserId, streamId, args, opts) => effect('ai/mod-command', { channel_user_id: channelUserId, stream_id: streamId, args, by: opts && opts.by }).then((r) => r.reply),
arenaCommand: (client, cmd, parts) => fire('arena-command', { client: clientHandle(client), cmd, parts }),
arenaCommand: (client, cmd, parts) => fire('arena-command', { client: clientHandle(client), cmd, parts }),
- mediaQueue: (op, args) => effect('media-queue', { op, ...args }),
- hardwareCommand: (streamerUserId, command, fromUser) => effect('hardware', { streamer_user_id: streamerUserId, command, from_user: fromUser }),
+ mediaQueue: async (op, args) => await effect('media-queue', { op, ...args }),
+ hardwareCommand: async (streamerUserId, command, fromUser) => await effect('hardware', { streamer_user_id: streamerUserId, command, from_user: fromUser }),
createPaste: (body) => effect('paste', body).then((r) => r.paste),
createPaste: (body) => effect('paste', body).then((r) => r.paste),
translate: (text, channelUserId) => effect('translate', { text, channel_user_id: channelUserId }).then((r) => r.translation || null),
translate: (text, channelUserId) => effect('translate', { text, channel_user_id: channelUserId }).then((r) => r.translation || null),
notifyDm: (body) => fire('notify/dm', body),
notifyDm: (body) => fire('notify/dm', body),
@@ -925,7 +926,7 @@ const effects = {
@@ -925,7 +926,7 @@ const effects = {
notifyCallInvite: (body) => fire('notify/call-invite', body),
notifyCallInvite: (body) => fire('notify/call-invite', body),
// Awaitable (a sound's Media copy is removed from Live's row before the row goes), never throws.
// Awaitable (a sound's Media copy is removed from Live's row before the row goes), never throws.
assetSync: (op, assetId) => effect('asset-sync', { op, asset_id: assetId || null }).catch((err) => console.warn(`[LiveContext] asset-sync: ${err.message}`)),
assetSync: (op, assetId) => effect('asset-sync', { op, asset_id: assetId || null }).catch((err) => console.warn(`[LiveContext] asset-sync: ${err.message}`)),
- userProfile: (username, viewerId) => read(`/users/profile?username=${encodeURIComponent(username)}${viewerId ? `&viewer_id=${viewerId}` : ''}`),
+ userProfile: async (username, viewerId) => await read(`/users/profile?username=${encodeURIComponent(username)}${viewerId ? `&viewer_id=${viewerId}` : ''}`),
};
};
module.exports = {
module.exports = {
@@ -79,66 +79,11 @@ class RoomError extends Error {
@@ -79,66 +79,11 @@ class RoomError extends Error {
}
}
const fail = (status, code, message) => { throw new RoomError(status, code, message); };
const fail = (status, code, message) => { throw new RoomError(status, code, message); };
-let ready = false;
-function ensureSchema() {
- if (ready) return;
- const d = db.getDb();
- d.exec(`
- CREATE TABLE IF NOT EXISTS rooms (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- slug TEXT UNIQUE NOT NULL,
- name TEXT NOT NULL,
- topic TEXT,
- kind TEXT NOT NULL DEFAULT 'community',
- visibility TEXT NOT NULL DEFAULT 'public',
- owner_id INTEGER NOT NULL,
- owner_subject TEXT,
- slow_seconds INTEGER NOT NULL DEFAULT 0,
- message_count INTEGER NOT NULL DEFAULT 0,
- last_message_at DATETIME,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- archived_at DATETIME
- );
- CREATE TABLE IF NOT EXISTS room_members (
- room_id INTEGER NOT NULL,
- user_id INTEGER NOT NULL,
- role TEXT NOT NULL DEFAULT 'member',
- last_read_id INTEGER NOT NULL DEFAULT 0,
- joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (room_id, user_id)
- );
- CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);
- CREATE TABLE IF NOT EXISTS room_messages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- room_id INTEGER NOT NULL,
- user_id INTEGER NOT NULL,
- subject_id TEXT,
- message TEXT NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- is_deleted INTEGER NOT NULL DEFAULT 0,
- deleted_by INTEGER
- );
- CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_id, id);
- CREATE TABLE IF NOT EXISTS room_attachments (
- room_id INTEGER NOT NULL,
- service TEXT NOT NULL,
- resource TEXT NOT NULL,
- title TEXT,
- attached_by INTEGER NOT NULL,
- attached_by_subject TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (room_id, service, resource)
- );
- `);
- // Additive columns on tables the first release created (ADD COLUMN is a metadata change: safe live).
- const cols = (t) => new Set(d.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name));
- if (!cols('rooms').has('join_role')) d.exec('ALTER TABLE rooms ADD COLUMN join_role TEXT');
- if (!cols('room_members').has('last_seen_at')) d.exec('ALTER TABLE room_members ADD COLUMN last_seen_at DATETIME');
- ready = true;
-}
+// The rooms tables are in migrations/0001_initial.sql (PostgreSQL); nothing is created at runtime.
+async function ensureSchema() {}
const isStaff = (user) => !!user && permissions.can(user, 'staff.moderation.chat');
const isStaff = (user) => !!user && permissions.can(user, 'staff.moderation.chat');
-const roleOf = (roomId, userId) => (userId ? (db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);
+const roleOf = async (roomId, userId) => (userId ? (await db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);
const siteBanned = (user) => !!user && (user.is_banned || (() => { try { return ctx.isUserBanned(user.id, null); } catch { return false; } })());
const siteBanned = (user) => !!user && (user.is_banned || (() => { try { return ctx.isUserBanned(user.id, null); } catch { return false; } })());
const kindOf = (room) => (KINDS.includes(room && room.kind) ? room.kind : 'community');
const kindOf = (room) => (KINDS.includes(room && room.kind) ? room.kind : 'community');
const joinRoleOf = (room) => (kindOf(room) === 'call' && JOIN_ROLES_CALL.includes(room.join_role) ? room.join_role : JOIN_ROLE[kindOf(room)]);
const joinRoleOf = (room) => (kindOf(room) === 'call' && JOIN_ROLES_CALL.includes(room.join_role) ? room.join_role : JOIN_ROLE[kindOf(room)]);
@@ -147,29 +92,29 @@ function slugify(name) {
@@ -147,29 +92,29 @@ function slugify(name) {
return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
}
}
-function publicRoom(r, extra = {}) {
+async function publicRoom(r, extra = {}) {
if (!r) return null;
if (!r) return null;
return {
return {
id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: kindOf(r), visibility: r.visibility,
id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: kindOf(r), visibility: r.visibility,
...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),
...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),
slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,
slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,
- created_at: r.created_at, members: (db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
+ created_at: r.created_at, members: (await db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
...extra,
...extra,
};
};
}
}
-function bySlug(slug) {
- ensureSchema();
- return db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;
+async function bySlug(slug) {
+ await ensureSchema();
+ return await db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;
}
}
/**
/**
* What `user` may do in `room`: { role, read, post, join, talk, moderate, manage } — the matrix above.
* What `user` may do in `room`: { role, read, post, join, talk, moderate, manage } — the matrix above.
* `join` and `talk` are the room's call (call rooms only).
* `join` and `talk` are the room's call (call rooms only).
*/
*/
-function access(room, user) {
+async function access(room, user) {
const kind = kindOf(room);
const kind = kindOf(room);
- const role = user ? roleOf(room.id, user.id) : null;
+ const role = user ? await roleOf(room.id, user.id) : null;
const staff = isStaff(user);
const staff = isStaff(user);
const blocked = role === 'blocked';
const blocked = role === 'blocked';
const member = !!role && !blocked;
const member = !!role && !blocked;
@@ -182,8 +127,8 @@ function access(room, user) {
@@ -182,8 +127,8 @@ function access(room, user) {
return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };
return { role, kind, read, post, join, talk, moderate, manage: !!user && !blocked && (role === 'owner' || staff) };
}
}
-function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {
- ensureSchema();
+async function create(user, { name, slug, topic, visibility = 'public', kind = 'community', join_role = null } = {}) {
+ await ensureSchema();
if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
const n = String(name || '').replace(/\s+/g, ' ').trim();
const n = String(name || '').replace(/\s+/g, ' ').trim();
@@ -195,59 +140,59 @@ function create(user, { name, slug, topic, visibility = 'public', kind = 'commun
@@ -195,59 +140,59 @@ function create(user, { name, slug, topic, visibility = 'public', kind = 'commun
if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');
if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');
if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');
if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');
- const owned = db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id]).n;
+ const owned = (await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id])).n;
if (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);
if (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);
- if (bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');
+ if (await bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;
const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;
- const id = db.transaction(() => {
- const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ const id = await db.tx(async () => {
+ const r = await db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[s, n, t, kind, visibility, user.id, user.subject_id || null, kind === 'call' ? (join_role || JOIN_ROLE.call) : null]);
[s, n, t, kind, visibility, user.id, user.subject_id || null, kind === 'call' ? (join_role || JOIN_ROLE.call) : null]);
- db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', CURRENT_TIMESTAMP)", [r.lastInsertRowid, user.id]);
+ await db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', ov_now())", [r.lastInsertRowid, user.id]);
return Number(r.lastInsertRowid);
return Number(r.lastInsertRowid);
});
});
- return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });
+ return await publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });
}
}
/** Public rooms by recent activity, plus the rooms `user` belongs to with unread counts. */
/** Public rooms by recent activity, plus the rooms `user` belongs to with unread counts. */
-function list(user, { limit = 50 } = {}) {
- ensureSchema();
+async function list(user, { limit = 50 } = {}) {
+ await ensureSchema();
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
- const pub = db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim]).map((r) => publicRoom(r));
- const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,
+ const pub = (await Promise.all((await db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim])).map(async (r) => await publicRoom(r))));
+ const mine = user ? (await Promise.all((await db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,
(SELECT COUNT(*) FROM room_messages x WHERE x.room_id = r.id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != ?) AS unread
(SELECT COUNT(*) FROM room_messages x WHERE x.room_id = r.id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != ?) AS unread
FROM room_members m JOIN rooms r ON r.id = m.room_id
FROM room_members m JOIN rooms r ON r.id = m.room_id
WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL
WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL
- ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })) : [];
+ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id])).map(async (r) => await publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })))) : [];
return { public: pub, mine };
return { public: pub, mine };
}
}
/** Unread messages across the rooms `user` belongs to (the site's navigation shows it). */
/** Unread messages across the rooms `user` belongs to (the site's navigation shows it). */
-function unreadTotal(user) {
+async function unreadTotal(user) {
if (!user) return 0;
if (!user) return 0;
- ensureSchema();
- return (db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL
+ await ensureSchema();
+ return (await db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL
JOIN room_messages x ON x.room_id = m.room_id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != m.user_id
JOIN room_messages x ON x.room_id = m.room_id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != m.user_id
WHERE m.user_id = ? AND m.role != 'blocked'`, [user.id]) || {}).n || 0;
WHERE m.user_id = ? AND m.role != 'blocked'`, [user.id]) || {}).n || 0;
}
}
/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */
/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */
-function join(room, user) {
+async function join(room, user) {
if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');
- const role = roleOf(room.id, user.id);
+ const role = await roleOf(room.id, user.id);
if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');
if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');
if (role) return role;
if (role) return role;
// A private room is not there for someone it has not invited (the same answer as a missing room).
// A private room is not there for someone it has not invited (the same answer as a missing room).
if (room.visibility !== 'public' && !isStaff(user)) fail(404, 'rooms.not_found', 'No such room');
if (room.visibility !== 'public' && !isStaff(user)) fail(404, 'rooms.not_found', 'No such room');
- const last = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
+ const last = (await db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
const r = joinRoleOf(room);
const r = joinRoleOf(room);
- db.run('INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)', [room.id, user.id, r, last]);
+ await db.run('INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, ?, ?, ov_now())', [room.id, user.id, r, last]);
return r;
return r;
}
}
-function leave(room, user) {
- const role = roleOf(room.id, user && user.id);
+async function leave(room, user) {
+ const role = await roleOf(room.id, user && user.id);
if (role === 'owner') fail(409, 'rooms.owner_leaves', 'The owner cannot leave their room');
if (role === 'owner') fail(409, 'rooms.owner_leaves', 'The owner cannot leave their room');
- if (role && role !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);
+ if (role && role !== 'blocked') await db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);
}
}
const _rate = new Map();
const _rate = new Map();
@@ -266,9 +211,9 @@ function rateOk(key, now = Date.now()) {
@@ -266,9 +211,9 @@ function rateOk(key, now = Date.now()) {
// message, or turning the room private, announces chat.room.message.deleted with a redaction, so what
// message, or turning the room private, announces chat.room.message.deleted with a redaction, so what
// the room had published stops being replayable. Both are written in the transaction of the change.
// the room had published stops being replayable. Both are written in the transaction of the change.
const REDACT_CHUNK = 500;
const REDACT_CHUNK = 500;
-function announceMessage(room, user, id, message, now) {
+async function announceMessage(room, user, id, message, now) {
if (room.visibility !== 'public') return;
if (room.visibility !== 'public') return;
- require('../events/outbox').enqueue({
+ await require('../events/outbox').enqueue({
event_type: 'chat.room.message.created',
event_type: 'chat.room.message.created',
visibility: 'public',
visibility: 'public',
actorSubject: user.subject_id || null,
actorSubject: user.subject_id || null,
@@ -284,11 +229,11 @@ function announceMessage(room, user, id, message, now) {
@@ -284,11 +229,11 @@ function announceMessage(room, user, id, message, now) {
},
},
});
});
}
}
-function announceDeleted(room, ids) {
+async function announceDeleted(room, ids) {
const list = [...new Set(ids.map(Number).filter((n) => Number.isInteger(n) && n > 0))];
const list = [...new Set(ids.map(Number).filter((n) => Number.isInteger(n) && n > 0))];
for (let i = 0; i < list.length; i += REDACT_CHUNK) {
for (let i = 0; i < list.length; i += REDACT_CHUNK) {
const part = list.slice(i, i + REDACT_CHUNK);
const part = list.slice(i, i + REDACT_CHUNK);
- require('../events/outbox').enqueue({
+ await require('../events/outbox').enqueue({
event_type: 'chat.room.message.deleted',
event_type: 'chat.room.message.deleted',
visibility: 'public',
visibility: 'public',
subject: { type: 'chat_room_message', id: String(part[0]) },
subject: { type: 'chat_room_message', id: String(part[0]) },
@@ -297,8 +242,8 @@ function announceDeleted(room, ids) {
@@ -297,8 +242,8 @@ function announceDeleted(room, ids) {
}
}
}
}
-function post(room, user, text, { now = Date.now() } = {}) {
- const a = access(room, user);
+async function post(room, user, text, { now = Date.now() } = {}) {
+ const a = await access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
if (!a.post) {
if (!a.post) {
@@ -312,53 +257,53 @@ function post(room, user, text, { now = Date.now() } = {}) {
@@ -312,53 +257,53 @@ function post(room, user, text, { now = Date.now() } = {}) {
if (wordFilter.isSpam(body)) fail(422, 'rooms.spam', 'That looks like spam');
if (wordFilter.isSpam(body)) fail(422, 'rooms.spam', 'That looks like spam');
if (!rateOk(`${room.id}:${user.id}`, now)) fail(429, 'rooms.slow_down', 'Slow down a little');
if (!rateOk(`${room.id}:${user.id}`, now)) fail(429, 'rooms.slow_down', 'Slow down a little');
if (room.slow_seconds > 0 && !a.moderate) {
if (room.slow_seconds > 0 && !a.moderate) {
- const last = db.get('SELECT created_at FROM room_messages WHERE room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1', [room.id, user.id]);
+ const last = await db.get('SELECT created_at FROM room_messages WHERE room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1', [room.id, user.id]);
if (last && now - Date.parse(`${String(last.created_at).replace(' ', 'T')}Z`) < room.slow_seconds * 1000) fail(429, 'rooms.slow_mode', `Slow mode: one message every ${room.slow_seconds} seconds`);
if (last && now - Date.parse(`${String(last.created_at).replace(' ', 'T')}Z`) < room.slow_seconds * 1000) fail(429, 'rooms.slow_mode', `Slow mode: one message every ${room.slow_seconds} seconds`);
}
}
const filtered = wordFilter.check(body);
const filtered = wordFilter.check(body);
const message = filtered.safe ? body : filtered.filtered;
const message = filtered.safe ? body : filtered.filtered;
- const id = db.transaction(() => {
- const r = db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);
- db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?', [room.id]);
- db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
- announceMessage(room, user, Number(r.lastInsertRowid), message, now);
+ const id = await db.tx(async () => {
+ const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);
+ await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);
+ await db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
+ await announceMessage(room, user, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
return Number(r.lastInsertRowid);
});
});
- return messageById(id);
+ return await messageById(id);
}
}
const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, u.username, u.display_name, u.profile_color, u.role AS user_role
const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, u.username, u.display_name, u.profile_color, u.role AS user_role
FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id`;
FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id`;
-function messageById(id) { return db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null; }
+async function messageById(id) { return await db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null; }
/** Messages oldest → newest: the newest page, one before `before`, or everything after `after`. */
/** Messages oldest → newest: the newest page, one before `before`, or everything after `after`. */
-function history(room, { before = null, after = null, limit = 60 } = {}) {
+async function history(room, { before = null, after = null, limit = 60 } = {}) {
const lim = Math.min(200, Math.max(1, Number(limit) || 60));
const lim = Math.min(200, Math.max(1, Number(limit) || 60));
- if (after != null) return db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?`, [room.id, Number(after) || 0, lim]);
+ if (after != null) return await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?`, [room.id, Number(after) || 0, lim]);
const rows = before != null
const rows = before != null
- ? db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])
- : db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);
+ ? await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])
+ : await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);
return rows.reverse();
return rows.reverse();
}
}
-function markRead(room, user, lastId) {
+async function markRead(room, user, lastId) {
if (!user) return;
if (!user) return;
- const top = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
+ const top = (await db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
const to = Math.min(top, Number(lastId) || top);
const to = Math.min(top, Number(lastId) || top);
- db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?), last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
+ await db.run('UPDATE room_members SET last_read_id = GREATEST(last_read_id, ?), last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
}
}
-function deleteMessage(room, user, messageId) {
- const m = db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);
+async function deleteMessage(room, user, messageId) {
+ const m = await db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);
if (!m || m.is_deleted) fail(404, 'rooms.no_message', 'No such message');
if (!m || m.is_deleted) fail(404, 'rooms.no_message', 'No such message');
const own = user && m.user_id === user.id;
const own = user && m.user_id === user.id;
- const a = access(room, user);
+ const a = await access(room, user);
if (!own && !a.moderate) fail(403, 'rooms.not_yours', 'Only the author or a room moderator can delete this');
if (!own && !a.moderate) fail(403, 'rooms.not_yours', 'Only the author or a room moderator can delete this');
- db.transaction(() => {
- db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);
- if (room.visibility === 'public') announceDeleted(room, [m.id]);
+ await db.tx(async () => {
+ await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);
+ if (room.visibility === 'public') await announceDeleted(room, [m.id]);
});
});
- if (!own) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });
+ if (!own) await db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });
return m.id;
return m.id;
}
}
@@ -367,34 +312,34 @@ function deleteMessage(room, user, messageId) {
@@ -367,34 +312,34 @@ function deleteMessage(room, user, messageId) {
* remove them ('none'). The role must be one of the room kind's (KIND_ROLES); mods are the owner's (or
* remove them ('none'). The role must be one of the room kind's (KIND_ROLES); mods are the owner's (or
* chat staff's) to appoint and remove; everything else is any moderator's.
* chat staff's) to appoint and remove; everything else is any moderator's.
*/
*/
-function setRole(room, actor, targetUserId, role) {
+async function setRole(room, actor, targetUserId, role) {
const kind = kindOf(room);
const kind = kindOf(room);
if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');
if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');
if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');
if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');
if (role !== 'none' && !KIND_ROLES[kind].includes(role)) fail(422, 'rooms.role_kind', `A ${kind} room has no ${role} role (its roles: ${KIND_ROLES[kind].filter((r) => r !== 'owner').join(', ')})`);
if (role !== 'none' && !KIND_ROLES[kind].includes(role)) fail(422, 'rooms.role_kind', `A ${kind} room has no ${role} role (its roles: ${KIND_ROLES[kind].filter((r) => r !== 'owner').join(', ')})`);
- const a = access(room, actor);
+ const a = await access(room, actor);
if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');
if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');
- const current = roleOf(room.id, targetUserId);
+ const current = await roleOf(room.id, targetUserId);
if (current === 'owner') fail(403, 'rooms.owner', 'The owner\'s role does not change');
if (current === 'owner') fail(403, 'rooms.owner', 'The owner\'s role does not change');
if (role === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner appoints moderators');
if (role === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner appoints moderators');
if (current === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner changes a moderator');
if (current === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner changes a moderator');
- const target = ctx.getUserById(targetUserId);
+ const target = await ctx.getUserById(targetUserId);
if (!target) fail(404, 'rooms.no_user', 'No such person');
if (!target) fail(404, 'rooms.no_user', 'No such person');
- if (role === 'none') { if (current && current !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }
- else if (current) db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);
- else db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);
+ if (role === 'none') { if (current && current !== 'blocked') await db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }
+ else if (current) await db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);
+ else await db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);
const action = role === 'blocked' && current !== 'blocked' ? 'room_block'
const action = role === 'blocked' && current !== 'blocked' ? 'room_block'
: current === 'blocked' && role !== 'blocked' ? 'room_unblock'
: current === 'blocked' && role !== 'blocked' ? 'room_unblock'
: role === 'mod' && current !== 'mod' ? 'room_mod_add'
: role === 'mod' && current !== 'mod' ? 'room_mod_add'
: current === 'mod' && role !== 'mod' ? 'room_mod_remove'
: current === 'mod' && role !== 'mod' ? 'room_mod_remove'
: role === 'speaker' && current !== 'speaker' ? 'room_speaker_add'
: role === 'speaker' && current !== 'speaker' ? 'room_speaker_add'
: current === 'speaker' && role !== 'speaker' ? 'room_speaker_remove' : null;
: current === 'speaker' && role !== 'speaker' ? 'room_speaker_remove' : null;
- if (action) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: action, details: { from: current, to: role } });
+ if (action) await db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: action, details: { from: current, to: role } });
return role;
return role;
}
}
-function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {
- if (!access(room, actor).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
+async function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {
+ if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
const next = { ...room };
const next = { ...room };
if (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < 3 || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', 'Room names are 3 to 40 characters'); next.name = n; }
if (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < 3 || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', 'Room names are 3 to 40 characters'); next.name = n; }
if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;
if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;
@@ -404,21 +349,21 @@ function update(room, actor, { name, topic, visibility, slow_seconds, join_role
@@ -404,21 +349,21 @@ function update(room, actor, { name, topic, visibility, slow_seconds, join_role
if (kindOf(room) !== 'call' || !JOIN_ROLES_CALL.includes(join_role)) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
if (kindOf(room) !== 'call' || !JOIN_ROLES_CALL.includes(join_role)) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
next.join_role = join_role;
next.join_role = join_role;
}
}
- db.transaction(() => {
- db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, room.id]);
+ await db.tx(async () => {
+ await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, room.id]);
// Public → private: everything the room published stops being replayable on the realtime plane.
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
if (room.visibility === 'public' && next.visibility === 'private') {
- announceDeleted(room, db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id]).map((r) => r.id));
+ await announceDeleted(room, (await db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id])).map((r) => r.id));
}
}
});
});
- return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
+ return await publicRoom(await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
}
}
/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
-function members(room, { seen = false } = {}) {
+async function members(room, { seen = false } = {}) {
const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
- return db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
- WHERE m.room_id = ? ORDER BY ${order}, u.username COLLATE NOCASE LIMIT 500`, [room.id]);
+ return await db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
+ WHERE m.room_id = ? ORDER BY ${order}, lower(u.username) LIMIT 500`, [room.id]);
}
}
// ── Attachments: the room's side of a link from another service (a Community space) ──
// ── Attachments: the room's side of a link from another service (a Community space) ──
@@ -431,43 +376,43 @@ function attachmentShape(r) {
@@ -431,43 +376,43 @@ function attachmentShape(r) {
}
}
/** Attach the room to { service, resource, title? }. Idempotent: → { attachment, created }. Managers only. */
/** Attach the room to { service, resource, title? }. Idempotent: → { attachment, created }. Managers only. */
-function attach(room, user, { service, resource, title } = {}) {
- ensureSchema();
+async function attach(room, user, { service, resource, title } = {}) {
+ await ensureSchema();
if (!user) fail(401, 'rooms.sign_in', 'Sign in first');
if (!user) fail(401, 'rooms.sign_in', 'Sign in first');
- if (!access(room, user).manage) fail(403, 'rooms.not_owner', 'Only the room\'s owner attaches it elsewhere');
+ if (!(await access(room, user)).manage) fail(403, 'rooms.not_owner', 'Only the room\'s owner attaches it elsewhere');
const svc = ATTACH_SERVICES[service];
const svc = ATTACH_SERVICES[service];
if (!svc) fail(422, 'rooms.attach_service', `A room attaches to: ${Object.keys(ATTACH_SERVICES).join(', ')}`);
if (!svc) fail(422, 'rooms.attach_service', `A room attaches to: ${Object.keys(ATTACH_SERVICES).join(', ')}`);
const res = String(resource || '').toLowerCase();
const res = String(resource || '').toLowerCase();
if (!svc.resource.test(res)) fail(422, 'rooms.attach_resource', `That is not a ${service} ${svc.noun}`);
if (!svc.resource.test(res)) fail(422, 'rooms.attach_resource', `That is not a ${service} ${svc.noun}`);
const t = title == null ? null : String(title).replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) || null;
const t = title == null ? null : String(title).replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80) || null;
- const existing = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res]);
+ const existing = await db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res]);
if (existing) {
if (existing) {
- if (t && t !== existing.title) db.run('UPDATE room_attachments SET title = ? WHERE room_id = ? AND service = ? AND resource = ?', [t, room.id, service, res]);
+ if (t && t !== existing.title) await db.run('UPDATE room_attachments SET title = ? WHERE room_id = ? AND service = ? AND resource = ?', [t, room.id, service, res]);
return { attachment: attachmentShape({ ...existing, title: t || existing.title }), created: false };
return { attachment: attachmentShape({ ...existing, title: t || existing.title }), created: false };
}
}
- db.run('INSERT INTO room_attachments (room_id, service, resource, title, attached_by, attached_by_subject) VALUES (?, ?, ?, ?, ?, ?)', [room.id, service, res, t, user.id, user.subject_id || null]);
- return { attachment: attachmentShape(db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res])), created: true };
+ await db.run('INSERT INTO room_attachments (room_id, service, resource, title, attached_by, attached_by_subject) VALUES (?, ?, ?, ?, ?, ?)', [room.id, service, res, t, user.id, user.subject_id || null]);
+ return { attachment: attachmentShape(await db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, service, res])), created: true };
}
}
/** Remove an attachment: the room's managers, or the person who made it. Idempotent: → { removed }. */
/** Remove an attachment: the room's managers, or the person who made it. Idempotent: → { removed }. */
-function detach(room, user, service, resource) {
- ensureSchema();
+async function detach(room, user, service, resource) {
+ await ensureSchema();
if (!user) fail(401, 'rooms.sign_in', 'Sign in first');
if (!user) fail(401, 'rooms.sign_in', 'Sign in first');
- const row = db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, String(service || ''), String(resource || '').toLowerCase()]);
+ const row = await db.get('SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, String(service || ''), String(resource || '').toLowerCase()]);
// Refused before "not attached": otherwise a member could tell which attachments exist (only the
// Refused before "not attached": otherwise a member could tell which attachments exist (only the
// owner may list them) from 403 versus { removed: false }.
// owner may list them) from 403 versus { removed: false }.
- if (!access(room, user).manage && !(row && row.attached_by === user.id)) fail(403, 'rooms.not_owner', 'Only the room\'s owner, or whoever attached it, detaches it');
+ if (!(await access(room, user)).manage && !(row && row.attached_by === user.id)) fail(403, 'rooms.not_owner', 'Only the room\'s owner, or whoever attached it, detaches it');
if (!row) return { removed: false };
if (!row) return { removed: false };
- db.run('DELETE FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, row.service, row.resource]);
+ await db.run('DELETE FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?', [room.id, row.service, row.resource]);
return { removed: true };
return { removed: true };
}
}
-function attachments(room) {
- ensureSchema();
- return db.all('SELECT * FROM room_attachments WHERE room_id = ? ORDER BY created_at, service, resource', [room.id]).map(attachmentShape);
+async function attachments(room) {
+ await ensureSchema();
+ return (await db.all('SELECT * FROM room_attachments WHERE room_id = ? ORDER BY created_at, service, resource', [room.id])).map(attachmentShape);
}
}
-function _reset() { ready = false; _rate.clear(); }
+function _reset() { _rate.clear(); }
module.exports = {
module.exports = {
ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
@@ -54,10 +54,10 @@ const chatServer = () => require('../chat/chat-server');
@@ -54,10 +54,10 @@ const chatServer = () => require('../chat/chat-server');
* After a role or room change: sockets following the room learn what they may do now, and a running
* After a role or room change: sockets following the room learn what they may do now, and a running
* call in it follows the room's roles (no-op when calls are off or nobody is in).
* call in it follows the room's roles (no-op when calls are off or nobody is in).
*/
*/
-function applyRoomChange(room) {
- const fresh = rooms.bySlug(room.slug) || room;
- try { chatServer().refreshRoomAccess(fresh); } catch (err) { console.warn('[Rooms] socket update:', err.message); }
- try { require('../calls/call-server').applyRoomAccess(fresh); } catch (err) { console.warn('[Rooms] call update:', err.message); }
+async function applyRoomChange(room) {
+ const fresh = await rooms.bySlug(room.slug) || room;
+ try { await chatServer().refreshRoomAccess(fresh); } catch (err) { console.warn('[Rooms] socket update:', err.message); }
+ try { await require('../calls/call-server').applyRoomAccess(fresh); } catch (err) { console.warn('[Rooms] call update:', err.message); }
}
}
/** The room's call as the room page shows it: on or off, and who is in. */
/** The room's call as the room page shows it: on or off, and who is in. */
function callOf(room) {
function callOf(room) {
@@ -71,120 +71,120 @@ function callOf(room) {
@@ -71,120 +71,120 @@ function callOf(room) {
const personOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Attach rooms with your own sign-in, not an API token', code: 'rooms.person_only' }) : next());
const personOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Attach rooms with your own sign-in, not an API token', code: 'rooms.person_only' }) : next());
/** The room behind :slug when the caller may read it (else a 404 that says nothing about private rooms). */
/** The room behind :slug when the caller may read it (else a 404 that says nothing about private rooms). */
-function readable(req, res) {
- const room = rooms.bySlug(req.params.slug);
- if (!room || !rooms.access(room, req.user || null).read) { res.status(404).json({ error: 'No such room', code: 'rooms.not_found' }); return null; }
+async function readable(req, res) {
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, req.user || null)).read) { res.status(404).json({ error: 'No such room', code: 'rooms.not_found' }); return null; }
return room;
return room;
}
}
-router.get('/', optionalAuth, (req, res) => {
- try { res.set('Cache-Control', 'private, no-store').json(rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
+router.get('/', optionalAuth, async (req, res) => {
+ try { res.set('Cache-Control', 'private, no-store').json(await rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
});
});
-router.post('/', requireAuth, createLimit, (req, res) => {
- try { res.status(201).json({ room: rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
+router.post('/', requireAuth, createLimit, async (req, res) => {
+ try { res.status(201).json({ room: await rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
});
});
-router.get('/:slug', optionalAuth, (req, res) => {
- const room = readable(req, res); if (!room) return;
- const a = rooms.access(room, req.user || null);
+router.get('/:slug', optionalAuth, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
+ const a = await rooms.access(room, req.user || null);
res.set('Cache-Control', 'private, no-store').json({
res.set('Cache-Control', 'private, no-store').json({
- room: rooms.publicRoom(room, { role: a.role }),
+ room: await rooms.publicRoom(room, { role: a.role }),
can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },
can: { read: a.read, post: a.post, join: a.join, talk: a.talk, moderate: a.moderate, manage: a.manage },
...(room.kind === 'call' ? { call: callOf(room) } : {}),
...(room.kind === 'call' ? { call: callOf(room) } : {}),
});
});
});
});
-router.patch('/:slug', requireAuth, manageLimit, (req, res) => {
- const room = readable(req, res); if (!room) return;
- try { const out = rooms.update(room, req.user, req.body || {}); applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }
+router.patch('/:slug', requireAuth, manageLimit, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
+ try { const out = await rooms.update(room, req.user, req.body || {}); await applyRoomChange(room); res.json({ room: out }); } catch (err) { send(res, err); }
});
});
-router.get('/:slug/messages', optionalAuth, (req, res) => {
- const room = readable(req, res); if (!room) return;
+router.get('/:slug/messages', optionalAuth, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
try {
try {
- const messages = rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });
+ const messages = await rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });
res.set('Cache-Control', 'private, no-store').json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });
res.set('Cache-Control', 'private, no-store').json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.post('/:slug/messages', requireAuth, postLimit, (req, res) => {
- const room = readable(req, res); if (!room) return;
+router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
try {
try {
- const message = rooms.post(room, req.user, req.body && req.body.message);
+ const message = await rooms.post(room, req.user, req.body && req.body.message);
chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
res.status(201).json({ message });
res.status(201).json({ message });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.delete('/:slug/messages/:id', requireAuth, deleteLimit, (req, res) => {
- const room = readable(req, res); if (!room) return;
+router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
try {
try {
- const id = rooms.deleteMessage(room, req.user, parseInt(req.params.id, 10));
+ const id = await rooms.deleteMessage(room, req.user, parseInt(req.params.id, 10));
chatServer().broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });
chatServer().broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });
res.json({ ok: true, id });
res.json({ ok: true, id });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.post('/:slug/join', requireAuth, manageLimit, (req, res) => {
- const room = rooms.bySlug(req.params.slug);
+router.post('/:slug/join', requireAuth, manageLimit, async (req, res) => {
+ const room = await rooms.bySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
- try { const role = rooms.join(room, req.user); applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }
+ try { const role = await rooms.join(room, req.user); await applyRoomChange(room); res.json({ ok: true, role }); } catch (err) { send(res, err); }
});
});
-router.post('/:slug/leave', requireAuth, manageLimit, (req, res) => {
- const room = readable(req, res); if (!room) return;
+router.post('/:slug/leave', requireAuth, manageLimit, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
try {
try {
- rooms.leave(room, req.user);
+ await rooms.leave(room, req.user);
chatServer().removeFromRoom(room.id, req.user.id);
chatServer().removeFromRoom(room.id, req.user.id);
- applyRoomChange(room);
+ await applyRoomChange(room);
res.json({ ok: true });
res.json({ ok: true });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.post('/:slug/read', requireAuth, readLimit, (req, res) => {
- const room = readable(req, res); if (!room) return;
- try { rooms.markRead(room, req.user, req.body && req.body.last_id); res.json({ ok: true }); } catch (err) { send(res, err); }
+router.post('/:slug/read', requireAuth, readLimit, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
+ try { await rooms.markRead(room, req.user, req.body && req.body.last_id); res.json({ ok: true }); } catch (err) { send(res, err); }
});
});
-router.get('/:slug/members', optionalAuth, (req, res) => {
- const room = readable(req, res); if (!room) return;
- const moderate = rooms.access(room, req.user || null).moderate;
- res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room, { seen: moderate }).filter((m) => m.role !== 'blocked' || moderate) });
+router.get('/:slug/members', optionalAuth, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
+ const moderate = (await rooms.access(room, req.user || null)).moderate;
+ res.set('Cache-Control', 'private, no-store').json({ members: (await rooms.members(room, { seen: moderate })).filter((m) => m.role !== 'blocked' || moderate) });
});
});
router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
- const room = readable(req, res); if (!room) return;
+ const room = await readable(req, res); if (!room) return;
try {
try {
const name = String((req.body && req.body.username) || '').trim();
const name = String((req.body && req.body.username) || '').trim();
- let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
+ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await ctx.getUserByUsername(name) : null;
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });
if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });
- const role = rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));
+ const role = await rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));
if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);
if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);
- applyRoomChange(room);
+ await applyRoomChange(room);
res.json({ ok: true, user_id: target.id, username: target.username, role });
res.json({ ok: true, user_id: target.id, username: target.username, role });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.get('/:slug/attachments', requireAuth, (req, res) => {
- const room = readable(req, res); if (!room) return;
- if (!rooms.access(room, req.user).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });
- res.set('Cache-Control', 'private, no-store').json({ attachments: rooms.attachments(room) });
+router.get('/:slug/attachments', requireAuth, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
+ if (!(await rooms.access(room, req.user)).manage) return res.status(403).json({ error: 'Only the room\'s owner sees where it is attached', code: 'rooms.not_owner' });
+ res.set('Cache-Control', 'private, no-store').json({ attachments: await rooms.attachments(room) });
});
});
-router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, (req, res) => {
- const room = readable(req, res); if (!room) return;
+router.post('/:slug/attachments', requireAuth, personOnly, attachLimit, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
try {
try {
const b = req.body || {};
const b = req.body || {};
- const out = rooms.attach(room, req.user, { service: b.service, resource: b.resource, title: b.title });
- res.status(out.created ? 201 : 200).json({ ...out, room: rooms.publicRoom(room) });
+ const out = await rooms.attach(room, req.user, { service: b.service, resource: b.resource, title: b.title });
+ res.status(out.created ? 201 : 200).json({ ...out, room: await rooms.publicRoom(room) });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
-router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, attachLimit, (req, res) => {
- const room = readable(req, res); if (!room) return;
- try { res.json({ ok: true, ...rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }
+router.delete('/:slug/attachments/:service/:resource', requireAuth, personOnly, attachLimit, async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
+ try { res.json({ ok: true, ...await rooms.detach(room, req.user, req.params.service, req.params.resource) }); } catch (err) { send(res, err); }
});
});
module.exports = router;
module.exports = router;
@@ -38,7 +38,7 @@ function enabled() {
@@ -38,7 +38,7 @@ function enabled() {
function build() {
function build() {
const tokenClient = {
const tokenClient = {
- authHeaders: () => serviceAuth.headers(AUDIENCE, SCOPE),
+ authHeaders: async () => await serviceAuth.headers(AUDIENCE, SCOPE),
invalidate: () => serviceAuth.invalidate(AUDIENCE, SCOPE),
invalidate: () => serviceAuth.invalidate(AUDIENCE, SCOPE),
};
};
const vip = createVipClient({
const vip = createVipClient({
@@ -23,10 +23,10 @@ function dayOf(ts) {
@@ -23,10 +23,10 @@ function dayOf(ts) {
}
}
/** Public, indexable pages: the fixed ones plus every public room, each with a real lastmod. */
/** Public, indexable pages: the fixed ones plus every public room, each with a real lastmod. */
-function publicPages() {
- const pages = rooms.list(null, { limit: 100 }).public;
+async function publicPages() {
+ const pages = (await rooms.list(null, { limit: 100 })).public;
const out = [
const out = [
- { path: '/', changefreq: 'hourly', priority: 1.0, lastmod: () => newestGlobalMessage() },
+ { path: '/', changefreq: 'hourly', priority: 1.0, lastmod: async () => await newestGlobalMessage() },
{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },
{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
];
];
@@ -36,9 +36,9 @@ function publicPages() {
@@ -36,9 +36,9 @@ function publicPages() {
return out;
return out;
}
}
-function newestGlobalMessage() {
+async function newestGlobalMessage() {
try {
try {
- const row = historyStore.page('global', { limit: 1 }).messages[0];
+ const row = (await historyStore.page('global', { limit: 1 })).messages[0];
return row ? dayOf(row.timestamp) : null;
return row ? dayOf(row.timestamp) : null;
} catch { return null; }
} catch { return null; }
}
}
@@ -101,13 +101,12 @@ function createDiscoveryRoutes({ config }) {
@@ -101,13 +101,12 @@ function createDiscoveryRoutes({ config }) {
}));
}));
});
});
- router.get('/sitemap.xml', (req, res) => {
- const urls = publicPages().map((p) => ({
- loc: abs(p.path),
- ...(p.lastmod && p.lastmod() ? { lastmod: p.lastmod() } : {}),
- changefreq: p.changefreq,
- priority: p.priority,
- }));
+ router.get('/sitemap.xml', async (req, res) => {
+ const urls = [];
+ for (const p of await publicPages()) {
+ const lastmod = p.lastmod ? await p.lastmod() : null;
+ urls.push({ loc: abs(p.path), ...(lastmod ? { lastmod } : {}), changefreq: p.changefreq, priority: p.priority });
+ }
res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));
res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(seo.sitemapXml(urls));
});
});
@@ -113,22 +113,22 @@ function createWebRoutes({ config }) {
@@ -113,22 +113,22 @@ function createWebRoutes({ config }) {
const site = config.web.baseUrl;
const site = config.web.baseUrl;
const siteOrigin = (() => { try { return new URL(site).origin; } catch { return null; } })();
const siteOrigin = (() => { try { return new URL(site).origin; } catch { return null; } })();
const webConfig = { ...config, baseUrl: site };
const webConfig = { ...config, baseUrl: site };
- const viewers = { resolve: async (req) => viewerOf(req) };
+ const viewers = { resolve: async (req) => await viewerOf(req) };
const formLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false });
const formLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false });
const sameSite = (req, res, next) => {
const sameSite = (req, res, next) => {
const o = req.get('origin');
const o = req.get('origin');
if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');
if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');
next();
next();
};
};
- const countsOf = (actor) => {
+ const countsOf = async (actor) => {
if (actor.kind !== 'user') return {};
if (actor.kind !== 'user') return {};
- const n = (fn) => { try { return Number(fn()) || 0; } catch { return 0; } };
- return { messages: n(() => dm.getTotalUnread(actor.user.id)), rooms: n(() => require('../rooms/rooms').unreadTotal(actor.user)) };
+ const n = async (fn) => { try { return Number(await fn()) || 0; } catch { return 0; } };
+ return { messages: await n(async () => await dm.getTotalUnread(actor.user.id)), rooms: await n(async () => await require('../rooms/rooms').unreadTotal(actor.user)) };
};
};
const page = async (req, res, status, o) => {
const page = async (req, res, status, o) => {
const actor = o.actor || await viewerOf(req);
const actor = o.actor || await viewerOf(req);
res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')
res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')
- .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: countsOf(actor), ...o }));
+ .send(renderPage({ config, actor, prefs: await prefsOf(actor), counts: await countsOf(actor), ...o }));
};
};
const needUser = async (req, res) => {
const needUser = async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
@@ -144,9 +144,9 @@ function createWebRoutes({ config }) {
@@ -144,9 +144,9 @@ function createWebRoutes({ config }) {
// ── Global chat ──
// ── Global chat ──
router.get('/', async (req, res) => {
router.get('/', async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
- const { messages, latest_id } = historyStore.page('global', { limit: 60 });
+ const { messages, latest_id } = await historyStore.page('global', { limit: 60 });
// Not the lines of people this reader blocked on the network (chat/network-blocks.js).
// Not the lines of people this reader blocked on the network (chat/network-blocks.js).
- const hidden = actor.kind === 'user' ? require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();
+ const hidden = actor.kind === 'user' ? await require('../chat/network-blocks').blockedUserIds(actor.user) : new Set();
const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));
const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat' && !(m.user_id && hidden.has(Number(m.user_id))));
const composer = actor.kind === 'user'
const composer = actor.kind === 'user'
? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">
? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">
@@ -187,16 +187,16 @@ ${composer}
@@ -187,16 +187,16 @@ ${composer}
router.get('/messages', async (req, res) => {
router.get('/messages', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
const me = actor.user;
- const convs = dm.getConversations(me.id);
- const rows = convs.map((c) => {
- const others = dm.getParticipants(c.id).filter((p) => p.id !== me.id);
+ const convs = await dm.getConversations(me.id);
+ const rows = (await Promise.all(convs.map(async (c) => {
+ const others = (await dm.getParticipants(c.id)).filter((p) => p.id !== me.id);
const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
const t = hhmm(c.last_message_at);
const t = hhmm(c.last_message_at);
return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;
return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;
- });
+ })));
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
- const blocked = dm.getBlockedUsers(me.id);
+ const blocked = await dm.getBlockedUsers(me.id);
const blockedList = blocked.length ? `<details class="oc-blocked"><summary>People you blocked (${blocked.length})</summary><ul class="oc-members">${blocked.map((b) => `<li><strong>${esc(b.display_name || b.username)}</strong> <span class="oc-muted">@${esc(b.username || '')}</span><form class="oc-inline" method="post" action="/messages/unblock/${Number(b.id)}"><button type="submit" class="oc-link">Unblock</button></form></li>`).join('')}</ul></details>` : '';
const blockedList = blocked.length ? `<details class="oc-blocked"><summary>People you blocked (${blocked.length})</summary><ul class="oc-members">${blocked.map((b) => `<li><strong>${esc(b.display_name || b.username)}</strong> <span class="oc-muted">@${esc(b.username || '')}</span><form class="oc-inline" method="post" action="/messages/unblock/${Number(b.id)}"><button type="submit" class="oc-link">Unblock</button></form></li>`).join('')}</ul></details>` : '';
const unread = convs.reduce((n, c) => n + (Number(c.unread_count) || 0), 0);
const unread = convs.reduce((n, c) => n + (Number(c.unread_count) || 0), 0);
await page(req, res, 200, {
await page(req, res, 200, {
@@ -213,7 +213,7 @@ ${blockedList}`,
@@ -213,7 +213,7 @@ ${blockedList}`,
router.post('/messages/unblock/:userId', formLimit, sameSite, async (req, res) => {
router.post('/messages/unblock/:userId', formLimit, sameSite, async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const id = parseInt(req.params.userId, 10);
const id = parseInt(req.params.userId, 10);
- if (Number.isInteger(id) && id > 0) dm.unblockUser(actor.user.id, id);
+ if (Number.isInteger(id) && id > 0) await dm.unblockUser(actor.user.id, id);
const back = /^\/messages\/\d+$/.test(String(req.body && req.body.back)) ? req.body.back : '/messages?unblocked=1';
const back = /^\/messages\/\d+$/.test(String(req.body && req.body.back)) ? req.body.back : '/messages?unblocked=1';
res.redirect(303, back);
res.redirect(303, back);
});
});
@@ -221,7 +221,7 @@ ${blockedList}`,
@@ -221,7 +221,7 @@ ${blockedList}`,
router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {
router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const name = String((req.body && req.body.username) || '').trim();
const name = String((req.body && req.body.username) || '').trim();
- let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
+ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await ctx.getUserByUsername(name) : null;
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target) return res.redirect(303, `/messages?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
if (!target) return res.redirect(303, `/messages?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
req.headers.authorization = `Bearer ${req.cookies.ov_token}`;
req.headers.authorization = `Bearer ${req.cookies.ov_token}`;
@@ -236,17 +236,17 @@ ${blockedList}`,
@@ -236,17 +236,17 @@ ${blockedList}`,
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
const me = actor.user;
const id = parseInt(req.params.id, 10);
const id = parseInt(req.params.id, 10);
- if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });
- const others = dm.getParticipants(id).filter((p) => p.id !== me.id);
- const conv = dm.getConversation(id);
+ if (!Number.isInteger(id) || !await dm.isParticipant(id, me.id)) return await page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });
+ const others = (await dm.getParticipants(id)).filter((p) => p.id !== me.id);
+ const conv = await dm.getConversation(id);
const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
- const msgs = dm.getMessages(id, 60).slice().sort((a, b) => a.id - b.id);
- try { dm.markRead(id, me.id); } catch { /* best effort */ }
+ const msgs = (await dm.getMessages(id, 60)).slice().sort((a, b) => a.id - b.id);
+ try { await dm.markRead(id, me.id); } catch { /* best effort */ }
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
// A 1:1 conversation: block or unblock the other person here; a block (either way) closes the composer.
// A 1:1 conversation: block or unblock the other person here; a block (either way) closes the composer.
const other = conv && !conv.is_group && others.length === 1 ? others[0] : null;
const other = conv && !conv.is_group && others.length === 1 ? others[0] : null;
- const iBlocked = other ? dm.hasBlocked(me.id, other.id) : false;
- const closed = other ? dm.isBlockedEither(me.id, other.id) : false;
+ const iBlocked = other ? await dm.hasBlocked(me.id, other.id) : false;
+ const closed = other ? await dm.isBlockedEither(me.id, other.id) : false;
const otherName = other ? esc(other.display_name || other.username) : '';
const otherName = other ? esc(other.display_name || other.username) : '';
let composer = `<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`;
let composer = `<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`;
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
@@ -265,11 +265,11 @@ ${composer}${tools}`,
@@ -265,11 +265,11 @@ ${composer}${tools}`,
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
const me = actor.user;
const id = parseInt(req.params.id, 10);
const id = parseInt(req.params.id, 10);
- if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return res.redirect(303, '/messages');
- const conv = dm.getConversation(id);
- const others = dm.getParticipants(id).filter((p) => p.id !== me.id);
+ if (!Number.isInteger(id) || !await dm.isParticipant(id, me.id)) return res.redirect(303, '/messages');
+ const conv = await dm.getConversation(id);
+ const others = (await dm.getParticipants(id)).filter((p) => p.id !== me.id);
if (!conv || conv.is_group || others.length !== 1) return res.redirect(303, `/messages/${id}?error=${encodeURIComponent('Only a one-to-one conversation has someone to block')}`);
if (!conv || conv.is_group || others.length !== 1) return res.redirect(303, `/messages/${id}?error=${encodeURIComponent('Only a one-to-one conversation has someone to block')}`);
- dm.blockUser(me.id, others[0].id);
+ await dm.blockUser(me.id, others[0].id);
res.redirect(303, `/messages/${id}`);
res.redirect(303, `/messages/${id}`);
});
});
@@ -372,12 +372,12 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
@@ -372,12 +372,12 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';
const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';
return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
};
};
- const applyRoomChange = (room) => roomRoutes.applyRoomChange(room);
+ const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
router.get('/rooms', async (req, res) => {
router.get('/rooms', async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
const me = actor.kind === 'user' ? actor.user : null;
const me = actor.kind === 'user' ? actor.user : null;
- const { public: pub, mine } = rooms.list(me);
+ const { public: pub, mine } = await rooms.list(me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const staff = !!me && permissions.can(me, 'staff.moderation.chat');
const staff = !!me && permissions.can(me, 'staff.moderation.chat');
const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new">
const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new">
@@ -407,15 +407,15 @@ ${create}`,
@@ -407,15 +407,15 @@ ${create}`,
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const b = req.body || {};
const b = req.body || {};
const kind = rooms.KINDS.includes(b.kind) ? b.kind : 'community';
const kind = rooms.KINDS.includes(b.kind) ? b.kind : 'community';
- const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public', kind, join_role: kind === 'call' ? b.join_role || null : null });
+ const room = await rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public', kind, join_role: kind === 'call' ? b.join_role || null : null });
res.redirect(303, `/r/${room.slug}`);
res.redirect(303, `/r/${room.slug}`);
}));
}));
const loadRoom = async (req, res) => {
const loadRoom = async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
const me = actor.kind === 'user' ? actor.user : null;
const me = actor.kind === 'user' ? actor.user : null;
- const room = rooms.bySlug(req.params.slug);
- const can = room ? rooms.access(room, me) : null;
+ const room = await rooms.bySlug(req.params.slug);
+ const can = room ? await rooms.access(room, me) : null;
if (!room || !can.read) { await page(req, res, 404, { actor, title: 'No such room', path: req.path, robots: 'noindex', body: '<h1>No such room</h1><p><a href="/rooms">All rooms</a></p>' }); return null; }
if (!room || !can.read) { await page(req, res, 404, { actor, title: 'No such room', path: req.path, robots: 'noindex', body: '<h1>No such room</h1><p><a href="/rooms">All rooms</a></p>' }); return null; }
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
return { actor, me, room, can };
return { actor, me, room, can };
@@ -442,8 +442,8 @@ ${people}
@@ -442,8 +442,8 @@ ${people}
router.get('/r/:slug', async (req, res) => {
router.get('/r/:slug', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const x = await loadRoom(req, res); if (!x) return;
const { actor, me, room, can } = x;
const { actor, me, room, can } = x;
- const msgs = rooms.history(room, { limit: 60 });
- if (me && can.role) rooms.markRead(room, me);
+ const msgs = await rooms.history(room, { limit: 60 });
+ if (me && can.role) await rooms.markRead(room, me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
let composer;
let composer;
@@ -466,7 +466,7 @@ ${people}
@@ -466,7 +466,7 @@ ${people}
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
body: `<p><a href="/rooms">← Rooms</a></p>
body: `<p><a href="/rooms">← Rooms</a></p>
<h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
<h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
-<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
+<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${(await rooms.publicRoom(room)).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
${error}
${error}
${isCall ? callPanel(room, can, me) : ''}
${isCall ? callPanel(room, can, me) : ''}
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
@@ -476,41 +476,41 @@ ${composer}${tools}`,
@@ -476,41 +476,41 @@ ${composer}${tools}`,
router.post('/r/:slug', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
- const room = rooms.bySlug(req.params.slug);
- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
- const message = rooms.post(room, actor.user, req.body && req.body.message);
+ const message = await rooms.post(room, actor.user, req.body && req.body.message);
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
res.redirect(303, `/r/${room.slug}#oc-compose`);
res.redirect(303, `/r/${room.slug}#oc-compose`);
}));
}));
router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
- const room = rooms.bySlug(req.params.slug);
- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
- rooms.join(room, actor.user);
- applyRoomChange(room);
+ await rooms.join(room, actor.user);
+ await applyRoomChange(room);
res.redirect(303, `/r/${room.slug}`);
res.redirect(303, `/r/${room.slug}`);
}));
}));
router.post('/r/:slug/leave', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/leave', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
- const room = rooms.bySlug(req.params.slug);
+ const room = await rooms.bySlug(req.params.slug);
if (!room) return res.redirect(303, '/rooms');
if (!room) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
- rooms.leave(room, actor.user);
+ await rooms.leave(room, actor.user);
require('../chat/chat-server').removeFromRoom(room.id, actor.user.id);
require('../chat/chat-server').removeFromRoom(room.id, actor.user.id);
- applyRoomChange(room);
+ await applyRoomChange(room);
res.redirect(303, '/rooms');
res.redirect(303, '/rooms');
}));
}));
router.post('/r/:slug/delete/:id', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/delete/:id', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
- const room = rooms.bySlug(req.params.slug);
- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
- const id = rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));
+ const id = await rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });
res.redirect(303, `/r/${room.slug}`);
res.redirect(303, `/r/${room.slug}`);
}));
}));
@@ -521,7 +521,7 @@ ${composer}${tools}`,
@@ -521,7 +521,7 @@ ${composer}${tools}`,
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
- const members = rooms.members(room, { seen: true });
+ const members = await rooms.members(room, { seen: true });
const kindRoles = rooms.KIND_ROLES[room.kind] || rooms.KIND_ROLES.community;
const kindRoles = rooms.KIND_ROLES[room.kind] || rooms.KIND_ROLES.community;
const choices = [...kindRoles.filter((r) => r !== 'owner' && (r !== 'mod' || can.manage)), 'none'];
const choices = [...kindRoles.filter((r) => r !== 'owner' && (r !== 'mod' || can.manage)), 'none'];
const roleOption = (r, current) => `<option value="${r}"${r === current ? ' selected' : ''}>${r === 'none' ? 'remove' : ROLE_LABEL[r] || r}</option>`;
const roleOption = (r, current) => `<option value="${r}"${r === current ? ' selected' : ''}>${r === 'none' ? 'remove' : ROLE_LABEL[r] || r}</option>`;
@@ -530,7 +530,7 @@ ${composer}${tools}`,
@@ -530,7 +530,7 @@ ${composer}${tools}`,
: (m.role === 'mod' && !can.manage) ? '<span class="oc-muted">moderator</span>'
: (m.role === 'mod' && !can.manage) ? '<span class="oc-muted">moderator</span>'
: `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select> <button type="submit">Set</button></form>`;
: `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${choices.map((r) => roleOption(r, m.role)).join('')}</select> <button type="submit">Set</button></form>`;
const addRole = rooms.joinRoleOf(room);
const addRole = rooms.joinRoleOf(room);
- const attachments = can.manage ? rooms.attachments(room) : [];
+ const attachments = can.manage ? await rooms.attachments(room) : [];
const communityUrl = config.web.communityUrl;
const communityUrl = config.web.communityUrl;
const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
await page(req, res, 200, {
await page(req, res, 200, {
@@ -559,41 +559,41 @@ ${attachList}`,
@@ -559,41 +559,41 @@ ${attachList}`,
router.post('/r/:slug/settings', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/settings', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
- const room = rooms.bySlug(req.params.slug);
- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
req.roomBack = `/r/${room.slug}/settings`;
const b = req.body || {};
const b = req.body || {};
- rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined });
- applyRoomChange(room);
+ await rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined });
+ await applyRoomChange(room);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
}));
router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
- const room = rooms.bySlug(req.params.slug);
- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
req.roomBack = `/r/${room.slug}/settings`;
const name = String((req.body && req.body.username) || '').trim();
const name = String((req.body && req.body.username) || '').trim();
- let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
+ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await ctx.getUserByUsername(name) : null;
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
if (!target) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
if (!target) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
- const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || rooms.joinRoleOf(room)));
+ const role = await rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || rooms.joinRoleOf(room)));
if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) require('../chat/chat-server').removeFromRoom(room.id, target.id);
if (role === 'blocked' || (room.visibility === 'private' && role === 'none')) require('../chat/chat-server').removeFromRoom(room.id, target.id);
- applyRoomChange(room);
+ await applyRoomChange(room);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
}));
router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
- const room = rooms.bySlug(req.params.slug);
- if (!room || !rooms.access(room, actor.user).read) return res.redirect(303, '/rooms');
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
req.roomBack = `/r/${room.slug}/settings`;
- rooms.detach(room, actor.user, req.params.service, req.params.resource);
+ await rooms.detach(room, actor.user, req.params.service, req.params.resource);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
}));
// ── What shipped, robots, sitemap ──
// ── What shipped, robots, sitemap ──
- router.get('/updates', (req, res) => page(req, res, 200, {
+ router.get('/updates', async (req, res) => await page(req, res, 200, {
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}));
}));
@@ -30,15 +30,15 @@ t('boot', async () => {
@@ -30,15 +30,15 @@ t('boot', async () => {
t('export, then erase and confirm', async () => {
t('export, then erase and confirm', async () => {
const accountData = require('../server/chat/account-data');
const accountData = require('../server/chat/account-data');
const d = require('../server/db/database').getDb();
const d = require('../server/db/database').getDb();
- d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'dana', 'hi otto', ?, ?), (?, 'otto', 'hi dana', ?, ?), (?, 'dana_old', 'old me', ?, ?)")
+ await d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'dana', 'hi otto', ?, ?), (?, 'otto', 'hi dana', ?, ?), (?, 'dana_old', 'old me', ?, ?)")
.run(dana.id, otto.id, dana.subject_id, otto.id, dana.id, otto.subject_id, old.id, otto.id, old.subject_id);
.run(dana.id, otto.id, dana.subject_id, otto.id, dana.id, otto.subject_id, old.id, otto.id, old.subject_id);
const pair = (await h.http('POST', '/api/dm/conversations', { token: dana.token, body: { user_ids: [otto.id] } })).body.conversation.id;
const pair = (await h.http('POST', '/api/dm/conversations', { token: dana.token, body: { user_ids: [otto.id] } })).body.conversation.id;
assert.ok((await h.http('POST', `/api/dm/conversations/${pair}/messages`, { token: dana.token, body: { message: 'psst' } })).status < 300);
assert.ok((await h.http('POST', `/api/dm/conversations/${pair}/messages`, { token: dana.token, body: { message: 'psst' } })).status < 300);
assert.ok((await h.http('POST', `/api/dm/conversations/${pair}/messages`, { token: otto.token, body: { message: 'what' } })).status < 300);
assert.ok((await h.http('POST', `/api/dm/conversations/${pair}/messages`, { token: otto.token, body: { message: 'what' } })).status < 300);
require('../server/chat/network-blocks').ensureSchema && require('../server/chat/network-blocks').ensureSchema();
require('../server/chat/network-blocks').ensureSchema && require('../server/chat/network-blocks').ensureSchema();
- d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(dana.subject_id, otto.subject_id, otto.subject_id, dana.subject_id);
- d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id, actor_subject_id) VALUES ('timeout', ?, ?, ?)").run(otto.id, dana.id, otto.subject_id);
- d.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (?, 'veteran')").run(dana.id);
+ await d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(dana.subject_id, otto.subject_id, otto.subject_id, dana.subject_id);
+ await d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id, actor_subject_id) VALUES ('timeout', ?, ?, ?)").run(otto.id, dana.id, otto.subject_id);
+ await d.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (?, 'veteran')").run(dana.id);
const sent = [];
const sent = [];
let failNext = false;
let failNext = false;
@@ -61,14 +61,14 @@ t('export, then erase and confirm', async () => {
@@ -61,14 +61,14 @@ t('export, then erase and confirm', async () => {
const del = ev('network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject: dana.subject_id, aliases: [old.subject_id], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() });
const del = ev('network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject: dana.subject_id, aliases: [old.subject_id], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() });
failNext = true;
failNext = true;
await assert.rejects(accountData.apply(del, { send }), /confirmation refused: 503/);
await assert.rejects(accountData.apply(del, { send }), /confirmation refused: 503/);
- const q = (sql, ...a) => d.prepare(sql).all(...a);
- assert.deepStrictEqual(q('SELECT message FROM chat_messages ORDER BY id').map((m) => m.message), ['hi dana'], "hers and the alias's gone; otto's stays");
- assert.deepStrictEqual(q('SELECT message FROM dm_messages ORDER BY id').map((m) => m.message), ['what'], 'the DM she sent goes, his reply stays');
- assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ?', pair).map((p) => p.user_id), [otto.id]);
- assert.strictEqual(q('SELECT * FROM network_blocks').length, 0, 'blocks both ways');
- assert.strictEqual(q('SELECT * FROM moderation_actions').length, 1, 'moderation history stays');
- assert.strictEqual(q('SELECT * FROM user_tags').length, 0, 'the tags table is Chat’s and goes');
- assert.strictEqual(d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id).username, `deleted-${dana.id}`);
+ const q = async (sql, ...a) => await d.prepare(sql).all(...a);
+ assert.deepStrictEqual((await q('SELECT message FROM chat_messages ORDER BY id')).map((m) => m.message), ['hi dana'], "hers and the alias's gone; otto's stays");
+ assert.deepStrictEqual((await q('SELECT message FROM dm_messages ORDER BY id')).map((m) => m.message), ['what'], 'the DM she sent goes, his reply stays');
+ assert.deepStrictEqual((await q('SELECT user_id FROM dm_participants WHERE conversation_id = ?', pair)).map((p) => p.user_id), [otto.id]);
+ assert.strictEqual((await q('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');
+ assert.strictEqual((await q('SELECT * FROM moderation_actions')).length, 1, 'moderation history stays');
+ assert.strictEqual((await q('SELECT * FROM user_tags')).length, 0, 'the tags table is Chat’s and goes');
+ assert.strictEqual((await d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id)).username, `deleted-${dana.id}`);
assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');
assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');
const conf = sent[1];
const conf = sent[1];
assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
@@ -57,16 +57,16 @@ t('the next minute opens the window again', async () => {
@@ -57,16 +57,16 @@ t('the next minute opens the window again', async () => {
t('a write has its own number: 20 REST sends a minute, the 21st refused before it is stored', async () => {
t('a write has its own number: 20 REST sends a minute, the 21st refused before it is stored', async () => {
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
- const stored = () => h.db.get("SELECT COUNT(*) AS n FROM chat_messages WHERE message LIKE 'limits %'").n;
+ const stored = async () => (await h.db.get("SELECT COUNT(*) AS n FROM chat_messages WHERE message LIKE 'limits %'")).n;
for (let i = 0; i < 20; i++) {
for (let i = 0; i < 20; i++) {
const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: `limits ${i} from alice` } });
const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: `limits ${i} from alice` } });
assert.strictEqual(r.status, 200, `send ${i + 1}: ${r.text}`);
assert.strictEqual(r.status, 200, `send ${i + 1}: ${r.text}`);
}
}
- const before = stored();
+ const before = await stored();
const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'limits one more' } });
const r = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'limits one more' } });
assert.deepStrictEqual([r.status, r.body.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
assert.deepStrictEqual([r.status, r.body.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
assert.ok(r.body.detail.includes('chat.message.send'), r.body.detail);
assert.ok(r.body.detail.includes('chat.message.send'), r.body.detail);
- assert.strictEqual(stored(), before, 'nothing stored');
+ assert.strictEqual(await stored(), before, 'nothing stored');
assert.strictEqual((await h.http('POST', '/api/chat/send', { token: bob.token, body: { message: 'limits hi from bob' } })).status, 200, 'another person still sends');
assert.strictEqual((await h.http('POST', '/api/chat/send', { token: bob.token, body: { message: 'limits hi from bob' } })).status, 200, 'another person still sends');
assert.strictEqual((await h.http('POST', '/api/chat/send', { token: 'nope', body: { message: 'x' } })).status, 401, 'a bad token: 401 from auth');
assert.strictEqual((await h.http('POST', '/api/chat/send', { token: 'nope', body: { message: 'x' } })).status, 401, 'a bad token: 401 from auth');
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * The audio queue's claim (plan T3 decision 3): two pumps — two module instances standing for two Chat processes,
+ * each with its own pool connections on one database — race over the same queued requests. The claim is one atomic
+ * UPDATE … WHERE state = 'queued' RETURNING *, so every request plays exactly once; the synth runs outside any
+ * transaction. And enqueue's dedupe is the unique (room, dedupe_key) index: concurrent enqueues of one key store
+ * one row. On PostgreSQL (npm run test:pg) the two pumps really run on separate connections.
+ */
+const assert = require('assert');
+const { suite } = require('./helpers');
+
+const t = suite('audio-claim');
+const db = require('../server/db/database');
+const QUEUE = require.resolve('../server/chat/audio-queue');
+
+/** A fresh copy of server/chat/audio-queue.js (its own rooms and timers), sharing the database module. */
+function instance(name, plays) {
+ delete require.cache[QUEUE];
+ const aq = require(QUEUE);
+ delete require.cache[QUEUE];
+ aq.init({
+ performers: {
+ tts: async () => {
+ await new Promise((r) => setTimeout(r, Math.random() * 15)); // a synth: outside any transaction
+ return { frame: { type: 'tts-audio', audio: '' }, durationMs: 1 };
+ },
+ },
+ deliver: (row, frame) => { if (frame.type === 'tts-audio') plays.push({ by: name, id: row.id }); },
+ });
+ return aq;
+}
+
+const plays = [];
+let a, b;
+
+t('boot', async () => {
+ await db.initDb();
+ a = instance('a', plays);
+ b = instance('b', plays);
+ assert.notStrictEqual(a, b);
+});
+
+t('two pumps over 40 rooms: every request plays exactly once', async () => {
+ const ROOMS = 40;
+ const ids = [];
+ // Queued rows no pump has seen yet (enqueue would start its own pump at once).
+ for (let i = 0; i < ROOMS; i++) {
+ const r = await db.run(`INSERT INTO audio_requests (room, stream_id, kind, state, label, payload, created_at)
+ VALUES (?, ?, 'tts', 'queued', ?, '{}', ?)`, [`stream:${9000 + i}`, 9000 + i, `r${i}`, Date.now()]);
+ ids.push(Number(r.lastInsertRowid));
+ }
+ // Both pumps on every room at once.
+ await Promise.all(ids.map((_, i) => Promise.all([a.pump(`stream:${9000 + i}`), b.pump(`stream:${9000 + i}`)])));
+ const until = Date.now() + 10000;
+ while (plays.length < ROOMS && Date.now() < until) await new Promise((r) => setTimeout(r, 20));
+ await new Promise((r) => setTimeout(r, 200)); // a double play would arrive after the first
+ const played = plays.map((p) => p.id).sort((x, y) => x - y);
+ assert.deepStrictEqual(played, [...ids].sort((x, y) => x - y), 'each request delivered once, none twice');
+ const rows = await db.all('SELECT id, state, attempts FROM audio_requests WHERE id = ANY(?) ORDER BY id', [ids]);
+ assert.ok(rows.every((r) => ['playing', 'played'].includes(r.state) && r.attempts === 1), JSON.stringify(rows));
+ const by = new Set(plays.map((p) => p.by));
+ console.log(` (plays by pump: ${[...by].map((n) => `${n} ${plays.filter((p) => p.by === n).length}`).join(', ')})`);
+});
+
+t('a request claimed by one pump is not claimed again', async () => {
+ const r = await a.enqueue({ kind: 'tts', streamId: 9100, label: 'once', payload: {} });
+ a.stop(); b.stop(); // no pump: claim by hand
+ const [x, y] = await Promise.all([a.claim(r.id), b.claim(r.id)]);
+ assert.strictEqual([x, y].filter(Boolean).length, 1, 'one claim wins');
+ assert.strictEqual((x || y).state, 'playing');
+ assert.strictEqual((await db.get('SELECT attempts FROM audio_requests WHERE id = ?', [r.id])).attempts, 1);
+});
+
+t('concurrent enqueues of one dedupe key store one row', async () => {
+ const outs = await Promise.all([a, b, a, b].map((q) => q.enqueue({ kind: 'tts', streamId: 9200, label: 'dup', payload: {}, dedupeKey: 'k1' })));
+ assert.strictEqual(outs.filter((o) => o.queued).length, 1, JSON.stringify(outs));
+ assert.ok(outs.filter((o) => !o.queued).every((o) => o.reason === 'duplicate'));
+ assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM audio_requests WHERE room = 'stream:9200'")).n, 1);
+});
+
+t.run(async () => { if (a) a.stop(); if (b) b.stop(); });
@@ -11,7 +11,6 @@
@@ -11,7 +11,6 @@
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
const t = suite('audio-queue');
const t = suite('audio-queue');
@@ -49,11 +48,11 @@ async function joined(user, ip, stream = streamId) {
@@ -49,11 +48,11 @@ async function joined(user, ip, stream = streamId) {
await ws.next((m) => m.type === 'auth');
await ws.next((m) => m.type === 'auth');
return ws;
return ws;
}
}
-const rows = () => h.db.all('SELECT * FROM audio_requests ORDER BY id');
-const byLabel = (label) => h.db.get('SELECT * FROM audio_requests WHERE label = ?', [label]);
+const rows = async () => await h.db.all('SELECT * FROM audio_requests ORDER BY id');
+const byLabel = async (label) => await h.db.get('SELECT * FROM audio_requests WHERE label = ?', [label]);
async function until(fn, ms = 4000) {
async function until(fn, ms = 4000) {
const end = Date.now() + ms;
const end = Date.now() + ms;
- for (;;) { const v = fn(); if (v) return v; if (Date.now() > end) throw new Error('condition not met in time'); await h.sleep(25); }
+ for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) throw new Error('condition not met in time'); await h.sleep(25); }
}
}
t('boot', async () => {
t('boot', async () => {
@@ -68,7 +67,7 @@ t('boot', async () => {
@@ -68,7 +67,7 @@ t('boot', async () => {
listenerUser = h.addUser('listener');
listenerUser = h.addUser('listener');
chatters = ['ann', 'ben', 'cat', 'dan'].map((n) => h.addUser(n));
chatters = ['ann', 'ben', 'cat', 'dan'].map((n) => h.addUser(n));
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
- h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)
+ await h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
await h.ctx.sync();
await h.ctx.sync();
listener = await joined(listenerUser, '198.51.100.90');
listener = await joined(listenerUser, '198.51.100.90');
@@ -103,13 +102,15 @@ t('TTS of chat messages: queued rows, delivered one at a time, each frame carrie
@@ -103,13 +102,15 @@ t('TTS of chat messages: queued rows, delivered one at a time, each frame carrie
first = await listener.next((m) => m.type === 'tts-audio' && m.message === 'hello from ann');
first = await listener.next((m) => m.type === 'tts-audio' && m.message === 'hello from ann');
assert.ok(first.request_id > 0);
assert.ok(first.request_id > 0);
assert.ok(first.audio && first.mimeType === 'audio/wav' && first.ttsKey, 'the frame clients already play');
assert.ok(first.audio && first.mimeType === 'audio/wav' && first.ttsKey, 'the frame clients already play');
- assert.strictEqual(aq.getRequest(first.request_id).state, 'playing');
- assert.strictEqual(byLabel('hello from ben').state, 'queued');
- assert.strictEqual(byLabel('hello from cat').state, 'queued');
+ assert.strictEqual((await aq.getRequest(first.request_id)).state, 'playing');
+ // The chat line is announced before its request is stored: wait for the rows, not just the frame.
+ await until(async () => !!(await byLabel('hello from ben')) && !!(await byLabel('hello from cat')), 3000);
+ assert.strictEqual((await byLabel('hello from ben')).state, 'queued');
+ assert.strictEqual((await byLabel('hello from cat')).state, 'queued');
assert.ok(await listener.none((m) => m.type === 'tts-audio' && m.message === 'hello from ben', 400), 'the next waits for the one playing');
assert.ok(await listener.none((m) => m.type === 'tts-audio' && m.message === 'hello from ben', 400), 'the next waits for the one playing');
second = await listener.next((m) => m.type === 'tts-audio' && m.message === 'hello from ben', 3000);
second = await listener.next((m) => m.type === 'tts-audio' && m.message === 'hello from ben', 3000);
assert.ok(Date.now() - t0 >= 1200, 'paced by the clip length');
assert.ok(Date.now() - t0 >= 1200, 'paced by the clip length');
- const r1 = aq.getRequest(first.request_id);
+ const r1 = await aq.getRequest(first.request_id);
assert.strictEqual(r1.state, 'played');
assert.strictEqual(r1.state, 'played');
assert.strictEqual(r1.duration_ms, 1200);
assert.strictEqual(r1.duration_ms, 1200);
assert.strictEqual(r1.kind, 'tts');
assert.strictEqual(r1.kind, 'tts');
@@ -120,7 +121,7 @@ t('only the broadcaster and moderators skip: /skiptts skips the playing clip and
@@ -120,7 +121,7 @@ t('only the broadcaster and moderators skip: /skiptts skips the playing clip and
const v = await joined(viewer, '198.51.100.110');
const v = await joined(viewer, '198.51.100.110');
v.sendJson({ type: 'chat', message: '/skiptts' });
v.sendJson({ type: 'chat', message: '/skiptts' });
await v.next((m) => m.type === 'system' && m.message === 'You do not have permission.');
await v.next((m) => m.type === 'system' && m.message === 'You do not have permission.');
- assert.strictEqual(aq.getRequest(second.request_id).state, 'playing');
+ assert.strictEqual((await aq.getRequest(second.request_id)).state, 'playing');
const s = await joined(streamer, '198.51.100.111');
const s = await joined(streamer, '198.51.100.111');
const at = Date.now();
const at = Date.now();
@@ -129,12 +130,12 @@ t('only the broadcaster and moderators skip: /skiptts skips the playing clip and
@@ -129,12 +130,12 @@ t('only the broadcaster and moderators skip: /skiptts skips the playing clip and
assert.match(reply.message, new RegExp(`#${second.request_id} from Ben`));
assert.match(reply.message, new RegExp(`#${second.request_id} from Ben`));
const stop = await listener.next((m) => m.type === 'audio-skip');
const stop = await listener.next((m) => m.type === 'audio-skip');
assert.strictEqual(stop.request_id, second.request_id);
assert.strictEqual(stop.request_id, second.request_id);
- const row = aq.getRequest(second.request_id);
+ const row = await aq.getRequest(second.request_id);
assert.strictEqual(row.state, 'skipped');
assert.strictEqual(row.state, 'skipped');
assert.strictEqual(row.actor, 'user:streamer');
assert.strictEqual(row.actor, 'user:streamer');
third = await listener.next((m) => m.type === 'tts-audio' && m.message === 'hello from cat');
third = await listener.next((m) => m.type === 'tts-audio' && m.message === 'hello from cat');
assert.ok(Date.now() - at < 900, 'no waiting out the skipped clip');
assert.ok(Date.now() - at < 900, 'no waiting out the skipped clip');
- const log = h.db.get("SELECT * FROM moderation_actions WHERE action_type = 'tts_skip' ORDER BY id DESC LIMIT 1");
+ const log = await h.db.get("SELECT * FROM moderation_actions WHERE action_type = 'tts_skip' ORDER BY id DESC LIMIT 1");
assert.strictEqual(log.actor_user_id, streamer.id);
assert.strictEqual(log.actor_user_id, streamer.id);
});
});
@@ -157,8 +158,8 @@ t('the queue over REST: list, skip by id, clear — moderators yes, others 403',
@@ -157,8 +158,8 @@ t('the queue over REST: list, skip by id, clear — moderators yes, others 403',
assert.strictEqual((await h.http('POST', '/api/tts/queue/skip', { token: viewer.token, body: { stream_id: streamId, id: two } })).status, 403);
assert.strictEqual((await h.http('POST', '/api/tts/queue/skip', { token: viewer.token, body: { stream_id: streamId, id: two } })).status, 403);
const sk = await h.http('POST', '/api/tts/queue/skip', { token: mod.token, body: { stream_id: streamId, id: two } });
const sk = await h.http('POST', '/api/tts/queue/skip', { token: mod.token, body: { stream_id: streamId, id: two } });
assert.strictEqual(sk.status, 200, sk.text);
assert.strictEqual(sk.status, 200, sk.text);
- assert.strictEqual(aq.getRequest(two).state, 'skipped');
- assert.strictEqual(aq.getRequest(third.request_id).state, 'playing', 'skipping a waiting request leaves the playing one');
+ assert.strictEqual((await aq.getRequest(two)).state, 'skipped');
+ assert.strictEqual((await aq.getRequest(third.request_id)).state, 'playing', 'skipping a waiting request leaves the playing one');
assert.ok(await listener.none((m) => m.type === 'audio-skip' && m.request_id === two, 150), 'nothing to stop on clients for a request never delivered');
assert.ok(await listener.none((m) => m.type === 'audio-skip' && m.request_id === two, 150), 'nothing to stop on clients for a request never delivered');
const cl = await h.http('POST', '/api/tts/queue/clear', { token: mod.token, body: { stream_id: streamId } });
const cl = await h.http('POST', '/api/tts/queue/clear', { token: mod.token, body: { stream_id: streamId } });
@@ -169,7 +170,7 @@ t('the queue over REST: list, skip by id, clear — moderators yes, others 403',
@@ -169,7 +170,7 @@ t('the queue over REST: list, skip by id, clear — moderators yes, others 403',
assert.strictEqual(cl.body.queue.playing, null);
assert.strictEqual(cl.body.queue.playing, null);
assert.deepStrictEqual(cl.body.queue.queued, []);
assert.deepStrictEqual(cl.body.queue.queued, []);
assert.ok(await listener.none((m) => m.type === 'tts-audio' && /^bot line/.test(m.message), 300), 'nothing cleared is read');
assert.ok(await listener.none((m) => m.type === 'tts-audio' && /^bot line/.test(m.message), 300), 'nothing cleared is read');
- assert.ok(h.db.get("SELECT 1 FROM moderation_actions WHERE action_type = 'tts_clear' AND actor_user_id = ?", [mod.id]));
+ assert.ok(await h.db.get("SELECT 1 FROM moderation_actions WHERE action_type = 'tts_clear' AND actor_user_id = ?", [mod.id]));
assert.strictEqual((await h.http('POST', '/api/tts/queue/skip', { token: mod.token, body: { stream_id: streamId } })).status, 409, 'nothing to skip');
assert.strictEqual((await h.http('POST', '/api/tts/queue/skip', { token: mod.token, body: { stream_id: streamId } })).status, 409, 'nothing to skip');
});
});
@@ -178,37 +179,38 @@ t('failures are recorded and the queue moves on', async () => {
@@ -178,37 +179,38 @@ t('failures are recorded and the queue moves on', async () => {
const BRIDGE = h.serviceToken(['chat.live_bridge.write', 'chat.message.send']);
const BRIDGE = h.serviceToken(['chat.live_bridge.write', 'chat.message.send']);
await h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'b', ops: ['no audio', 'throws', 'fine'].map((l, i) => ({ seq: i + 1, op: 'synthesizeAndBroadcastTTS', args: [streamId, 'ChatBot', l, null, 'ai', `ai:bot${i}`, null, `mf${i}`] })) } });
await h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'b', ops: ['no audio', 'throws', 'fine'].map((l, i) => ({ seq: i + 1, op: 'synthesizeAndBroadcastTTS', args: [streamId, 'ChatBot', l, null, 'ai', `ai:bot${i}`, null, `mf${i}`] })) } });
await listener.next((m) => m.type === 'tts-audio' && m.message === 'fine');
await listener.next((m) => m.type === 'tts-audio' && m.message === 'fine');
- assert.strictEqual(byLabel('no audio').state, 'failed');
- assert.strictEqual(byLabel('no audio').error, 'no audio');
- assert.strictEqual(byLabel('throws').state, 'failed');
- assert.strictEqual(byLabel('throws').error, 'voice service down');
- assert.strictEqual(byLabel('fine').state, 'playing');
+ assert.strictEqual((await byLabel('no audio')).state, 'failed');
+ assert.strictEqual((await byLabel('no audio')).error, 'no audio');
+ assert.strictEqual((await byLabel('throws')).state, 'failed');
+ assert.strictEqual((await byLabel('throws')).error, 'voice service down');
+ assert.strictEqual((await byLabel('fine')).state, 'playing');
});
});
t('the playing client reports: failed with its reason; final states stay final', async () => {
t('the playing client reports: failed with its reason; final states stay final', async () => {
- const fine = byLabel('fine');
+ const fine = await byLabel('fine');
const bad = await h.http('POST', `/api/tts/queue/${fine.id}/report`, { token: streamer.token, body: { stream_id: streamId, state: 'skipped' } });
const bad = await h.http('POST', `/api/tts/queue/${fine.id}/report`, { token: streamer.token, body: { stream_id: streamId, state: 'skipped' } });
assert.strictEqual(bad.status, 400);
assert.strictEqual(bad.status, 400);
const rep = await h.http('POST', `/api/tts/queue/${fine.id}/report`, { token: streamer.token, body: { stream_id: streamId, state: 'failed', error: 'NotAllowedError' } });
const rep = await h.http('POST', `/api/tts/queue/${fine.id}/report`, { token: streamer.token, body: { stream_id: streamId, state: 'failed', error: 'NotAllowedError' } });
assert.strictEqual(rep.status, 200, rep.text);
assert.strictEqual(rep.status, 200, rep.text);
- assert.strictEqual(aq.getRequest(fine.id).state, 'failed');
- assert.strictEqual(aq.getRequest(fine.id).error, 'NotAllowedError');
+ assert.strictEqual((await aq.getRequest(fine.id)).state, 'failed');
+ assert.strictEqual((await aq.getRequest(fine.id)).error, 'NotAllowedError');
assert.strictEqual((await h.http('POST', `/api/tts/queue/${fine.id}/report`, { token: streamer.token, body: { stream_id: streamId, state: 'played' } })).status, 409, 'final states stay final');
assert.strictEqual((await h.http('POST', `/api/tts/queue/${fine.id}/report`, { token: streamer.token, body: { stream_id: streamId, state: 'played' } })).status, 409, 'final states stay final');
});
});
t('a keyed request is queued once; a requester’s limit holds', async () => {
t('a keyed request is queued once; a requester’s limit holds', async () => {
synthPlan = [3, 3, 3, 3, 3, 3];
synthPlan = [3, 3, 3, 3, 3, 3];
- const before = rows().length;
- const a = h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', 'same message', null, null, 'user:ann', null, 'm777');
- const b = h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', 'same message', null, null, 'user:ann', null, 'm777');
+ const before = (await rows()).length;
+ const a = await h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', 'same message', null, null, 'user:ann', null, 'm777');
+ const b = await h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', 'same message', null, null, 'user:ann', null, 'm777');
assert.strictEqual(a.queued, true);
assert.strictEqual(a.queued, true);
assert.deepStrictEqual(b, { queued: false, reason: 'duplicate' });
assert.deepStrictEqual(b, { queued: false, reason: 'duplicate' });
// tts_max_queue_per_user defaults to 3 (queued or playing).
// tts_max_queue_per_user defaults to 3 (queued or playing).
- const more = [1, 2, 3].map((n) => h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', `more ${n}`, null, null, 'user:ann', null, `m78${n}`));
+ const more = [];
+ for (const n of [1, 2, 3]) more.push(await h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', `more ${n}`, null, null, 'user:ann', null, `m78${n}`));
assert.deepStrictEqual(more.map((x) => x.queued), [true, true, false]);
assert.deepStrictEqual(more.map((x) => x.queued), [true, true, false]);
assert.strictEqual(more[2].reason, 'full');
assert.strictEqual(more[2].reason, 'full');
- assert.strictEqual(rows().length, before + 3);
- assert.strictEqual(h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', '.not read', null, null, 'user:ann', null, 'm790'), undefined, "'.' opts a message out");
+ assert.strictEqual((await rows()).length, before + 3);
+ assert.strictEqual(await h.chatServer.synthesizeAndBroadcastTTS(streamId, 'Ann', '.not read', null, null, 'user:ann', null, 'm790'), undefined, "'.' opts a message out");
const s = await joined(streamer, '198.51.100.112');
const s = await joined(streamer, '198.51.100.112');
s.sendJson({ type: 'chat', message: '/cleartts' });
s.sendJson({ type: 'chat', message: '/cleartts' });
await s.next((m) => m.type === 'system' && /^Cleared 3 TTS\/sound requests/.test(m.message));
await s.next((m) => m.type === 'system' && /^Cleared 3 TTS\/sound requests/.test(m.message));
@@ -217,7 +219,7 @@ t('a keyed request is queued once; a requester’s limit holds', async () => {
@@ -217,7 +219,7 @@ t('a keyed request is queued once; a requester’s limit holds', async () => {
t('channel !sounds go through the same queue (announce at once, audio in turn)', async () => {
t('channel !sounds go through the same queue (announce at once, audio in turn)', async () => {
const file = path.join(process.env.SOUNDS_PATH, 'honk-test.wav');
const file = path.join(process.env.SOUNDS_PATH, 'honk-test.wav');
fs.writeFileSync(file, wav(0.8));
fs.writeFileSync(file, wav(0.8));
- h.db.createChannelSound({ channel_owner_id: streamer.id, command: 'honk', url: file, mime: 'audio/wav', duration_seconds: 0.8 });
+ await h.db.createChannelSound({ channel_owner_id: streamer.id, command: 'honk', url: file, mime: 'audio/wav', duration_seconds: 0.8 });
const c = await joined(chatters[3], '198.51.100.113');
const c = await joined(chatters[3], '198.51.100.113');
c.sendJson({ type: 'chat', message: '!honk' });
c.sendJson({ type: 'chat', message: '!honk' });
const ann = await listener.next((m) => m.type === 'chat' && m.message_type === 'channel-sound');
const ann = await listener.next((m) => m.type === 'chat' && m.message_type === 'channel-sound');
@@ -225,41 +227,41 @@ t('channel !sounds go through the same queue (announce at once, audio in turn)',
@@ -225,41 +227,41 @@ t('channel !sounds go through the same queue (announce at once, audio in turn)',
const audio = await listener.next((m) => m.type === 'soundboard-audio' && m.title === '!honk');
const audio = await listener.next((m) => m.type === 'soundboard-audio' && m.title === '!honk');
assert.strictEqual(audio.source, 'channel-sound');
assert.strictEqual(audio.source, 'channel-sound');
assert.strictEqual(Buffer.from(audio.audio, 'base64').length, fs.statSync(file).size);
assert.strictEqual(Buffer.from(audio.audio, 'base64').length, fs.statSync(file).size);
- const row = aq.getRequest(audio.request_id);
+ const row = await aq.getRequest(audio.request_id);
assert.strictEqual(row.kind, 'channel-sound');
assert.strictEqual(row.kind, 'channel-sound');
assert.strictEqual(row.duration_ms, 800);
assert.strictEqual(row.duration_ms, 800);
assert.strictEqual(row.dedupe_key, `m${ann.id}`);
assert.strictEqual(row.dedupe_key, `m${ann.id}`);
- await until(() => aq.getRequest(audio.request_id).state === 'played', 3000);
+ await until(async () => (await aq.getRequest(audio.request_id)).state === 'played', 3000);
});
});
// ── Restart: Chat as its own process ────────────────────────────────────────────────────────────
// ── Restart: Chat as its own process ────────────────────────────────────────────────────────────
-let chat, port, dbFile;
-const raw = (fn) => { const d = new Database(dbFile); try { return fn(d); } finally { d.close(); } };
+let chat, port;
+// This process's handle on the database the spawned Chat serves from (test/helpers/pg-preload.mjs).
+const raw = async (fn) => await fn(h.db.getDb());
t('restart: what was playing is finished, what waited plays in order once, stale requests expire', async () => {
t('restart: what was playing is finished, what waited plays in order once, stale requests expire', async () => {
tts.synthesize = realSynth.synthesize;
tts.synthesize = realSynth.synthesize;
tts.synthesizeUserVoice = realSynth.synthesizeUserVoice;
tts.synthesizeUserVoice = realSynth.synthesizeUserVoice;
- dbFile = path.join(h.tmp, 'chat.db');
for (const w of sockets) { try { w.close(); } catch { /* */ } }
for (const w of sockets) { try { w.close(); } catch { /* */ } }
await h.detach();
await h.detach();
// Long enough clips that the queue is still full when Chat stops.
// Long enough clips that the queue is still full when Chat stops.
const file = path.join(process.env.SOUNDS_PATH, 'long-test.wav');
const file = path.join(process.env.SOUNDS_PATH, 'long-test.wav');
fs.writeFileSync(file, wav(2));
fs.writeFileSync(file, wav(2));
- raw((d) => {
- d.prepare("INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds) VALUES (?, 'long', ?, 'audio/wav', 2)").run(streamer.id, file);
+ await raw(async (d) => {
+ await d.prepare("INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds) VALUES (?, 'long', ?, 'audio/wav', 2)").run(streamer.id, file);
// Left over from an earlier life: one queued an hour ago, one that crashed Chat three times.
// Left over from an earlier life: one queued an hour ago, one that crashed Chat three times.
const ins = d.prepare("INSERT INTO audio_requests (room, stream_id, kind, state, label, payload, created_at, attempts) VALUES (?, ?, 'channel-sound', 'queued', ?, ?, ?, ?)");
const ins = d.prepare("INSERT INTO audio_requests (room, stream_id, kind, state, label, payload, created_at, attempts) VALUES (?, ?, 'channel-sound', 'queued', ?, ?, ?, ?)");
- ins.run(`stream:${streamId}`, streamId, 'stale', JSON.stringify({ file, title: '!stale' }), Date.now() - 3600e3, 0);
- ins.run(`stream:${streamId}`, streamId, 'crashy', JSON.stringify({ file, title: '!crashy' }), Date.now(), 3);
+ await ins.run(`stream:${streamId}`, streamId, 'stale', JSON.stringify({ file, title: '!stale' }), Date.now() - 3600e3, 0);
+ await ins.run(`stream:${streamId}`, streamId, 'crashy', JSON.stringify({ file, title: '!crashy' }), Date.now(), 3);
});
});
port = await h.freePort();
port = await h.freePort();
chat = await h.spawnChat({ port });
chat = await h.spawnChat({ port });
- assert.strictEqual(raw((d) => d.prepare("SELECT state, error FROM audio_requests WHERE label = 'stale'").get()).error, 'expired');
+ assert.strictEqual((await raw(async (d) => await d.prepare("SELECT state, error FROM audio_requests WHERE label = 'stale'").get())).error, 'expired');
// The room's queue is held until it has listeners again (a second after the first rejoins).
// The room's queue is held until it has listeners again (a second after the first rejoins).
await h.sleep(300);
await h.sleep(300);
- assert.strictEqual(raw((d) => d.prepare("SELECT state FROM audio_requests WHERE label = 'crashy'").get().state), 'queued', 'held for listeners');
+ assert.strictEqual(await raw(async (d) => (await d.prepare("SELECT state FROM audio_requests WHERE label = 'crashy'").get()).state), 'queued', 'held for listeners');
const ears = await joined(listenerUser, '198.51.100.120');
const ears = await joined(listenerUser, '198.51.100.120');
- await until(() => raw((d) => d.prepare("SELECT state FROM audio_requests WHERE label = 'crashy'").get().state) === 'failed');
- assert.strictEqual(raw((d) => d.prepare("SELECT error FROM audio_requests WHERE label = 'crashy'").get()).error, 'gave up after repeated attempts');
+ await until(async () => await raw(async (d) => (await d.prepare("SELECT state FROM audio_requests WHERE label = 'crashy'").get()).state) === 'failed');
+ assert.strictEqual((await raw(async (d) => await d.prepare("SELECT error FROM audio_requests WHERE label = 'crashy'").get())).error, 'gave up after repeated attempts');
for (const [i, c] of chatters.slice(0, 3).entries()) {
for (const [i, c] of chatters.slice(0, 3).entries()) {
const ws = await joined(c, `198.51.100.${121 + i}`);
const ws = await joined(c, `198.51.100.${121 + i}`);
@@ -269,8 +271,8 @@ t('restart: what was playing is finished, what waited plays in order once, stale
@@ -269,8 +271,8 @@ t('restart: what was playing is finished, what waited plays in order once, stale
}
}
const firstClip = await ears.next((m) => m.type === 'soundboard-audio' && m.title === '!long');
const firstClip = await ears.next((m) => m.type === 'soundboard-audio' && m.title === '!long');
// The announce comes before the durable insert: wait for all three rows, not just the first clip.
// The announce comes before the durable insert: wait for all three rows, not just the first clip.
- await until(() => raw((d) => d.prepare("SELECT COUNT(*) AS n FROM audio_requests WHERE label = '!long'").get().n) === 3, 3000);
- const queued = raw((d) => d.prepare("SELECT id, state FROM audio_requests WHERE label = '!long' ORDER BY id").all());
+ await until(async () => await raw(async (d) => (await d.prepare("SELECT COUNT(*) AS n FROM audio_requests WHERE label = '!long'").get()).n) === 3, 3000);
+ const queued = await raw(async (d) => await d.prepare("SELECT id, state FROM audio_requests WHERE label = '!long' ORDER BY id").all());
assert.deepStrictEqual(queued.map((x) => x.state), ['playing', 'queued', 'queued']);
assert.deepStrictEqual(queued.map((x) => x.state), ['playing', 'queued', 'queued']);
assert.strictEqual(queued[0].id, firstClip.request_id);
assert.strictEqual(queued[0].id, firstClip.request_id);
@@ -278,14 +280,14 @@ t('restart: what was playing is finished, what waited plays in order once, stale
@@ -278,14 +280,14 @@ t('restart: what was playing is finished, what waited plays in order once, stale
assert.deepStrictEqual(exit, { code: 0, signal: null }, chat.log);
assert.deepStrictEqual(exit, { code: 0, signal: null }, chat.log);
chat = await h.spawnChat({ port });
chat = await h.spawnChat({ port });
await h.sleep(300);
await h.sleep(300);
- assert.strictEqual(raw((d) => d.prepare('SELECT state FROM audio_requests WHERE id = ?').get(queued[1].id).state), 'queued', 'nothing plays into an empty room');
+ assert.strictEqual(await raw(async (d) => (await d.prepare('SELECT state FROM audio_requests WHERE id = ?').get(queued[1].id)).state), 'queued', 'nothing plays into an empty room');
const ears2 = await joined(listenerUser, '198.51.100.130');
const ears2 = await joined(listenerUser, '198.51.100.130');
const got = [];
const got = [];
got.push(await ears2.next((m) => m.type === 'soundboard-audio' && m.title === '!long', 4000));
got.push(await ears2.next((m) => m.type === 'soundboard-audio' && m.title === '!long', 4000));
got.push(await ears2.next((m) => m.type === 'soundboard-audio' && m.title === '!long', 5000));
got.push(await ears2.next((m) => m.type === 'soundboard-audio' && m.title === '!long', 5000));
assert.deepStrictEqual(got.map((m) => m.request_id), [queued[1].id, queued[2].id], 'the waiting clips, in order');
assert.deepStrictEqual(got.map((m) => m.request_id), [queued[1].id, queued[2].id], 'the waiting clips, in order');
assert.ok(await ears2.none((m) => m.type === 'soundboard-audio' && m.request_id === queued[0].id, 100), 'the clip delivered before the restart is not played again');
assert.ok(await ears2.none((m) => m.type === 'soundboard-audio' && m.request_id === queued[0].id, 100), 'the clip delivered before the restart is not played again');
- const after = raw((d) => d.prepare("SELECT id, state, error FROM audio_requests WHERE label = '!long' ORDER BY id").all());
+ const after = await raw(async (d) => await d.prepare("SELECT id, state, error FROM audio_requests WHERE label = '!long' ORDER BY id").all());
assert.strictEqual(after[0].state, 'played');
assert.strictEqual(after[0].state, 'played');
assert.strictEqual(after[0].error, 'delivered before a restart');
assert.strictEqual(after[0].error, 'delivered before a restart');
assert.strictEqual(after[1].state, 'played');
assert.strictEqual(after[1].state, 'played');
@@ -295,7 +297,7 @@ t('restart: what was playing is finished, what waited plays in order once, stale
@@ -295,7 +297,7 @@ t('restart: what was playing is finished, what waited plays in order once, stale
const s = await joined(mod, '198.51.100.131');
const s = await joined(mod, '198.51.100.131');
s.sendJson({ type: 'chat', message: '/cleartts' });
s.sendJson({ type: 'chat', message: '/cleartts' });
await s.next((m) => m.type === 'system' && /^Cleared 1 TTS\/sound request\./.test(m.message));
await s.next((m) => m.type === 'system' && /^Cleared 1 TTS\/sound request\./.test(m.message));
- assert.strictEqual(raw((d) => d.prepare('SELECT state FROM audio_requests WHERE id = ?').get(queued[2].id).state), 'skipped');
+ assert.strictEqual(await raw(async (d) => (await d.prepare('SELECT state FROM audio_requests WHERE id = ?').get(queued[2].id)).state), 'skipped');
});
});
t.run(async () => {
t.run(async () => {
@@ -43,8 +43,8 @@ t('only Live’s service token, only from loopback', async () => {
@@ -43,8 +43,8 @@ t('only Live’s service token, only from loopback', async () => {
t('sending a chat message needs chat.message.send; without it only those ops are refused', async () => {
t('sending a chat message needs chat.message.send; without it only those ops are refused', async () => {
const APP = 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
const APP = 'app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
- const before = h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n;
- const outboxBefore = h.db.get('SELECT COUNT(*) AS n FROM events_outbox').n;
+ const before = (await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n;
+ const outboxBefore = (await h.db.get('SELECT COUNT(*) AS n FROM events_outbox')).n;
const commit = { hash: 'f'.repeat(40), short: 'fffffff', date: '2026-09-22T10:00:00Z', subject: 'denied deploy' };
const commit = { hash: 'f'.repeat(40), short: 'fffffff', date: '2026-09-22T10:00:00Z', subject: 'denied deploy' };
const sends = [
const sends = [
{ op: 'db', ref: REF - 50, key: 'live:denied:1', args: ['saveChatMessage', { stream_id: streamId, username: 'Bot', message: 'denied insert', message_type: 'chat' }] },
{ op: 'db', ref: REF - 50, key: 'live:denied:1', args: ['saveChatMessage', { stream_id: streamId, username: 'Bot', message: 'denied insert', message_type: 'chat' }] },
@@ -74,9 +74,9 @@ t('sending a chat message needs chat.message.send; without it only those ops are
@@ -74,9 +74,9 @@ t('sending a chat message needs chat.message.send; without it only those ops are
const denied = (m) => /denied (insert|line|dm|deploy)/.test(JSON.stringify(m));
const denied = (m) => /denied (insert|line|dm|deploy)/.test(JSON.stringify(m));
assert.ok(await viewerWs.none(denied), 'no refused message reached the stream room');
assert.ok(await viewerWs.none(denied), 'no refused message reached the stream room');
assert.ok(await globalWs.none(denied), 'no refused message reached global chat');
assert.ok(await globalWs.none(denied), 'no refused message reached global chat');
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n, before, 'nothing persisted');
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM events_outbox').n, outboxBefore, 'no chat.message.created');
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM bridge_applied WHERE key = ?', ['live:denied:1']).n, 0, 'a refused write is not marked applied');
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n, before, 'nothing persisted');
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM events_outbox')).n, outboxBefore, 'no chat.message.created');
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM bridge_applied WHERE key = ?', ['live:denied:1'])).n, 0, 'a refused write is not marked applied');
// The capability alone is not enough either: the bridge capability is still required.
// The capability alone is not enough either: the bridge capability is still required.
assert.strictEqual((await calls(sends.slice(0, 1), { token: h.serviceToken(['chat.message.send']) })).status, 403);
assert.strictEqual((await calls(sends.slice(0, 1), { token: h.serviceToken(['chat.message.send']) })).status, 403);
// An app principal holding both may send.
// An app principal holding both may send.
@@ -101,16 +101,16 @@ t('a forwarded insert gets its real id, and later ops of the boot are rewritten'
@@ -101,16 +101,16 @@ t('a forwarded insert gets its real id, and later ops of the boot are rewritten'
const cross = await globalWs.next((m) => m.type === 'chat' && m.message === 'beep boop');
const cross = await globalWs.next((m) => m.type === 'chat' && m.message === 'beep boop');
assert.strictEqual(cross.id, realId);
assert.strictEqual(cross.id, realId);
assert.strictEqual(cross.stream_channel, 'streamer');
assert.strictEqual(cross.stream_channel, 'streamer');
- const row = h.db.getChatMessageById(realId);
+ const row = await h.db.getChatMessageById(realId);
assert.strictEqual(row.channel_user_id, streamer.id, 'channel derived from the stream');
assert.strictEqual(row.channel_user_id, streamer.id, 'channel derived from the stream');
assert.strictEqual(row.source_platform, 'ai');
assert.strictEqual(row.source_platform, 'ai');
});
});
t('a retried write (same key) is applied once and answers the first result', async () => {
t('a retried write (same key) is applied once and answers the first result', async () => {
- const before = h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n;
+ const before = (await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n;
const r = await calls([{ op: 'db', ref: REF, key: 'live:1', args: ['saveChatMessage', { stream_id: streamId, username: 'ChatBot', message: 'beep boop', message_type: 'chat' }] }]);
const r = await calls([{ op: 'db', ref: REF, key: 'live:1', args: ['saveChatMessage', { stream_id: streamId, username: 'ChatBot', message: 'beep boop', message_type: 'chat' }] }]);
assert.strictEqual(r.body.results[0].result.lastInsertRowid, realId);
assert.strictEqual(r.body.results[0].result.lastInsertRowid, realId);
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n, before);
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n, before);
});
});
t('placeholders are per Live boot', async () => {
t('placeholders are per Live boot', async () => {
@@ -127,12 +127,12 @@ t('only allow-listed writes; unknown ops refused', async () => {
@@ -127,12 +127,12 @@ t('only allow-listed writes; unknown ops refused', async () => {
{ op: 'db', args: ['deleteChatMessage', realId, streamer.id] },
{ op: 'db', args: ['deleteChatMessage', realId, streamer.id] },
]);
]);
assert.deepStrictEqual(r.body.results.map((x) => x.ok), [false, false, true]);
assert.deepStrictEqual(r.body.results.map((x) => x.ok), [false, false, true]);
- assert.ok(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n > 0);
- assert.strictEqual(h.db.getChatMessageById(realId).is_deleted, 1);
+ assert.ok((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n > 0);
+ assert.strictEqual((await h.db.getChatMessageById(realId)).is_deleted, 1);
// Moderation from Live's /api/mod lands in Chat's log, with an internal event.
// Moderation from Live's /api/mod lands in Chat's log, with an internal event.
const m = await calls([{ op: 'db', ref: REF - 1, key: 'live:2', args: ['logModerationAction', { scope_type: 'site', actor_user_id: streamer.id, action_type: 'message_delete', details: { id: realId } }] }]);
const m = await calls([{ op: 'db', ref: REF - 1, key: 'live:2', args: ['logModerationAction', { scope_type: 'site', actor_user_id: streamer.id, action_type: 'message_delete', details: { id: realId } }] }]);
assert.ok(m.body.results[0].ok);
assert.ok(m.body.results[0].ok);
- const ev = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'").map((x) => JSON.parse(x.event)).pop();
+ const ev = (await h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'")).map((x) => JSON.parse(x.event)).pop();
assert.strictEqual(ev.payload.action_type, 'message_delete');
assert.strictEqual(ev.payload.action_type, 'message_delete');
assert.strictEqual(ev.visibility, 'internal');
assert.strictEqual(ev.visibility, 'internal');
});
});
@@ -147,11 +147,11 @@ t('DMs through the bridge keep the participant check', async () => {
@@ -147,11 +147,11 @@ t('DMs through the bridge keep the participant check', async () => {
});
});
t('a user update from Live refreshes sockets and rewrites stored names', async () => {
t('a user update from Live refreshes sockets and rewrites stored names', async () => {
- h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: 'Viewer', message: 'old name line', message_type: 'chat' });
+ await h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: 'Viewer', message: 'old name line', message_type: 'chat' });
await calls([{ op: 'sendUserUpdate', args: [viewer.id, { id: viewer.id, username: 'viewer', display_name: 'Viewer Renamed', role: 'user', avatar_url: null, profile_color: '#123456' }] }]);
await calls([{ op: 'sendUserUpdate', args: [viewer.id, { id: viewer.id, username: 'viewer', display_name: 'Viewer Renamed', role: 'user', avatar_url: null, profile_color: '#123456' }] }]);
const u = await viewerWs.next((m) => m.type === 'user-updated');
const u = await viewerWs.next((m) => m.type === 'user-updated');
assert.strictEqual(u.user.display_name, 'Viewer Renamed');
assert.strictEqual(u.user.display_name, 'Viewer Renamed');
- assert.strictEqual(h.db.get('SELECT username FROM chat_messages WHERE message = ?', ['old name line']).username, 'Viewer Renamed');
+ assert.strictEqual((await h.db.get('SELECT username FROM chat_messages WHERE message = ?', ['old name line'])).username, 'Viewer Renamed');
await h.sleep(1100);
await h.sleep(1100);
viewerWs.sendJson({ type: 'chat', message: 'with the new name' });
viewerWs.sendJson({ type: 'chat', message: 'with the new name' });
const own = await globalWs.next((m) => m.type === 'chat' && m.message === 'with the new name');
const own = await globalWs.next((m) => m.type === 'chat' && m.message === 'with the new name');
@@ -168,20 +168,20 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded o
@@ -168,20 +168,20 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded o
const commit = (n) => ({ hash: String(n).repeat(40).slice(0, 40), short: String(n).repeat(7), date: '2026-09-22T10:00:00Z', subject: `change ${n}` });
const commit = (n) => ({ hash: String(n).repeat(40).slice(0, 40), short: String(n).repeat(7), date: '2026-09-22T10:00:00Z', subject: `change ${n}` });
let r = await calls([{ op: 'deployNotice', args: [[commit(1)]] }]);
let r = await calls([{ op: 'deployNotice', args: [[commit(1)]] }]);
const id1 = r.body.results[0].result.id;
const id1 = r.body.results[0].result.id;
- const row = h.db.getChatMessageById(id1);
+ const row = await h.db.getChatMessageById(id1);
assert.strictEqual(row.message_type, 'system');
assert.strictEqual(row.message_type, 'system');
assert.strictEqual(JSON.parse(row.metadata).kind, 'deploy');
assert.strictEqual(JSON.parse(row.metadata).kind, 'deploy');
r = await calls([{ op: 'deployNotice', args: [[commit(2)]] }]);
r = await calls([{ op: 'deployNotice', args: [[commit(2)]] }]);
assert.strictEqual(r.body.results[0].result.id, id1, 'folded into the same row');
assert.strictEqual(r.body.results[0].result.id, id1, 'folded into the same row');
- assert.strictEqual(JSON.parse(h.db.getChatMessageById(id1).metadata).deploys, 2);
+ assert.strictEqual(JSON.parse((await h.db.getChatMessageById(id1)).metadata).deploys, 2);
// Someone speaks in a stream room (the global feed shows it): the next deploy is a new card,
// Someone speaks in a stream room (the global feed shows it): the next deploy is a new card,
// so no card's time range runs past a message under it.
// so no card's time range runs past a message under it.
- h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: 'hello brother', message_type: 'chat', is_global: false });
+ await h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: 'hello brother', message_type: 'chat', is_global: false });
r = await calls([{ op: 'deployNotice', args: [[commit(3)]] }]);
r = await calls([{ op: 'deployNotice', args: [[commit(3)]] }]);
const id3 = r.body.results[0].result.id;
const id3 = r.body.results[0].result.id;
assert.notStrictEqual(id3, id1, 'a message since the last card starts a new card');
assert.notStrictEqual(id3, id1, 'a message since the last card starts a new card');
- assert.strictEqual(JSON.parse(h.db.getChatMessageById(id3).metadata).deploys, 1);
- assert.strictEqual(JSON.parse(h.db.getChatMessageById(id1).metadata).deploys, 2, 'the old card is left as it was');
+ assert.strictEqual(JSON.parse((await h.db.getChatMessageById(id3)).metadata).deploys, 1);
+ assert.strictEqual(JSON.parse((await h.db.getChatMessageById(id1)).metadata).deploys, 2, 'the old card is left as it was');
// A late joiner gets it once.
// A late joiner gets it once.
const late = await h.ws({ ip: '198.51.100.52' });
const late = await h.ws({ ip: '198.51.100.52' });
late.sendJson({ type: 'join' });
late.sendJson({ type: 'join' });
@@ -191,11 +191,11 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded o
@@ -191,11 +191,11 @@ t('deploy notices: stored as one rolling global row, shown to everyone, folded o
});
});
t('the invalidate op is accepted for a channel and refuses an unknown kind', async () => {
t('the invalidate op is accepted for a channel and refuses an unknown kind', async () => {
- assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), false);
- h.db.addChannelModerator(channelId, viewer.id, streamer.id); // the moderator row is Chat's own (C-04)
+ assert.strictEqual(await h.ctx.isChannelModerator(viewer.id, channelId), false);
+ await h.db.addChannelModerator(channelId, viewer.id, streamer.id); // the moderator row is Chat's own (C-04)
const r = await calls([{ op: 'invalidate', args: ['channel', channelId] }, { op: 'invalidate', args: ['nonsense', 1] }]);
const r = await calls([{ op: 'invalidate', args: ['channel', channelId] }, { op: 'invalidate', args: ['nonsense', 1] }]);
assert.deepStrictEqual(r.body.results.map((x) => x.ok), [true, false]);
assert.deepStrictEqual(r.body.results.map((x) => x.ok), [true, false]);
- assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), true);
+ assert.strictEqual(await h.ctx.isChannelModerator(viewer.id, channelId), true);
});
});
t('presence snapshot for Live’s synchronous reads', async () => {
t('presence snapshot for Live’s synchronous reads', async () => {
@@ -59,7 +59,7 @@ async function join(opts) {
@@ -59,7 +59,7 @@ async function join(opts) {
}
}
const bye = async (...sockets) => { for (const ws of sockets) { if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); await ws.closed; } } await h.sleep(30); };
const bye = async (...sockets) => { for (const ws of sockets) { if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); await ws.closed; } } await h.sleep(30); };
const api = (method, path, u, body) => h.http(method, `/api/streams${path}`, { token: u && u.token, body });
const api = (method, path, u, body) => h.http(method, `/api/streams${path}`, { token: u && u.token, body });
-const rows = (where = {}) => lifecycle.list(where);
+const rows = async (where = {}) => await lifecycle.list(where);
async function until(fn, ms = 2000) {
async function until(fn, ms = 2000) {
const end = Date.now() + ms;
const end = Date.now() + ms;
for (;;) {
for (;;) {
@@ -86,7 +86,7 @@ t('boot (CHAT_CALLS=1, a short ring timeout)', async () => {
@@ -86,7 +86,7 @@ t('boot (CHAT_CALLS=1, a short ring timeout)', async () => {
const ch = h.addChannel(streamer.id);
const ch = h.addChannel(streamer.id);
liveStream = h.addStream(streamer.id, ch, { title: 'Late show' });
liveStream = h.addStream(streamer.id, ch, { title: 'Late show' });
offStream = h.addStream(streamer.id, ch, { title: 'Yesterday', is_live: 0 });
offStream = h.addStream(streamer.id, ch, { title: 'Yesterday', is_live: 0 });
- for (const u of [ann, bob, cat, dan, eve, kay, staff, streamer]) h.ctx.upsertUser(h.live.users.get(u.id));
+ for (const u of [ann, bob, cat, dan, eve, kay, staff, streamer]) await h.ctx.upsertUser(h.live.users.get(u.id));
await h.ctx.sync();
await h.ctx.sync();
assert.strictEqual(config.calls.enabled, true);
assert.strictEqual(config.calls.enabled, true);
});
});
@@ -136,13 +136,13 @@ t('public lobby: join, peer-joined, participant info, the list pushed to chat so
@@ -136,13 +136,13 @@ t('public lobby: join, peer-joined, participant info, the list pushed to chat so
const me = anon.welcome.participants.find((x) => x.peerId === anon.welcome.peerId);
const me = anon.welcome.participants.find((x) => x.peerId === anon.welcome.peerId);
assert.ok(/^anon\d+$/.test(me.anonId) && me.userId === null && me.displayName === me.anonId, 'signed out = anonymous, as on Live');
assert.ok(/^anon\d+$/.test(me.anonId) && me.userId === null && me.displayName === me.anonId, 'signed out = anonymous, as on Live');
- let session = rows({ channelId: 'public' })[0];
+ let session = (await rows({ channelId: 'public' }))[0];
assert.deepStrictEqual([session.kind, session.state, session.end_reason], ['channel', 'active', null]);
assert.deepStrictEqual([session.kind, session.state, session.end_reason], ['channel', 'active', null]);
await bye(b);
await bye(b);
const left = await a.next((m) => m.type === 'peer-left');
const left = await a.next((m) => m.type === 'peer-left');
assert.deepStrictEqual([left.peerId, left.reason, left.username], [b.welcome.peerId, 'disconnect', 'bob']);
assert.deepStrictEqual([left.peerId, left.reason, left.username], [b.welcome.peerId, 'disconnect', 'bob']);
await bye(a, anon, chat);
await bye(a, anon, chat);
- session = lifecycle.get(session.id);
+ session = await lifecycle.get(session.id);
assert.deepStrictEqual([session.state, session.end_reason], ['ended', 'empty'], 'the session ends when the channel empties');
assert.deepStrictEqual([session.state, session.end_reason], ['ended', 'empty'], 'the session ends when the channel empties');
assert.ok(session.started_at && session.ended_at >= session.started_at);
assert.ok(session.started_at && session.ended_at >= session.started_at);
assert.strictEqual(callServer.getParticipantCount('public'), 0);
assert.strictEqual(callServer.getParticipantCount('public'), 0);
@@ -269,7 +269,7 @@ t('ban and unban', async () => {
@@ -269,7 +269,7 @@ t('ban and unban', async () => {
await back.next((m) => m.type === 'call-ended');
await back.next((m) => m.type === 'call-ended');
await k.next((m) => m.type === 'call-ended');
await k.next((m) => m.type === 'call-ended');
assert.ok(!callServer.channels.has(kayChannel));
assert.ok(!callServer.channels.has(kayChannel));
- const session = rows({ channelId: kayChannel })[0];
+ const session = (await rows({ channelId: kayChannel }))[0];
assert.deepStrictEqual([session.kind, session.state, session.end_reason], ['channel', 'ended', 'ended']);
assert.deepStrictEqual([session.kind, session.state, session.end_reason], ['channel', 'ended', 'ended']);
await bye(back, k);
await bye(back, k);
});
});
@@ -307,7 +307,7 @@ t('call-user: ringing → accepted → active → ended; private call; Live aske
@@ -307,7 +307,7 @@ t('call-user: ringing → accepted → active → ended; private call; Live aske
annCall = r.body.channel.id;
annCall = r.body.channel.id;
const invite = await catChat.next((m) => m.type === 'vc-call-invite');
const invite = await catChat.next((m) => m.type === 'vc-call-invite');
assert.deepStrictEqual([invite.channelId, invite.channelName, invite.fromUserId, invite.fromUsername, invite.fromDisplayName], [annCall, 'Ann\'s call', ann.id, 'ann', 'Ann']);
assert.deepStrictEqual([invite.channelId, invite.channelName, invite.fromUserId, invite.fromUsername, invite.fromDisplayName], [annCall, 'Ann\'s call', ann.id, 'ann', 'Ann']);
- let ring = rows({ kind: 'direct' })[0];
+ let ring = (await rows({ kind: 'direct' }))[0];
assert.deepStrictEqual([ring.state, ring.created_by, ring.target_user_id, ring.channel_id, ring.created_by_subject, ring.target_subject], ['ringing', ann.id, cat.id, annCall, ann.subject_id, cat.subject_id]);
assert.deepStrictEqual([ring.state, ring.created_by, ring.target_user_id, ring.channel_id, ring.created_by_subject, ring.target_subject], ['ringing', ann.id, cat.id, annCall, ann.subject_id, cat.subject_id]);
const note = await until(() => h.live.effects.find((e) => e.name === 'notify/call-invite'));
const note = await until(() => h.live.effects.find((e) => e.name === 'notify/call-invite'));
assert.deepStrictEqual(note.body, { caller_id: ann.id, target_id: cat.id, channel_id: annCall, channel_name: 'Ann\'s call' });
assert.deepStrictEqual(note.body, { caller_id: ann.id, target_id: cat.id, channel_id: annCall, channel_name: 'Ann\'s call' });
@@ -330,15 +330,15 @@ t('call-user: ringing → accepted → active → ended; private call; Live aske
@@ -330,15 +330,15 @@ t('call-user: ringing → accepted → active → ended; private call; Live aske
assert.deepStrictEqual(r.body, { ok: true });
assert.deepStrictEqual(r.body, { ok: true });
const answer = await annChat.next((m) => m.type === 'vc-call-response');
const answer = await annChat.next((m) => m.type === 'vc-call-response');
assert.deepStrictEqual([answer.status, answer.channelId, answer.fromUserId, answer.fromUsername], ['accepted', annCall, cat.id, 'cat']);
assert.deepStrictEqual([answer.status, answer.channelId, answer.fromUserId, answer.fromUsername], ['accepted', annCall, cat.id, 'cat']);
- ring = lifecycle.get(ring.id);
+ ring = await lifecycle.get(ring.id);
assert.deepStrictEqual([ring.state, ring.end_reason], ['active', null]);
assert.deepStrictEqual([ring.state, ring.end_reason], ['active', null]);
assert.ok(ring.answered_at >= ring.started_at);
assert.ok(ring.answered_at >= ring.started_at);
const c = await join({ channelId: annCall, token: cat.token, ip: '198.51.100.43' });
const c = await join({ channelId: annCall, token: cat.token, ip: '198.51.100.43' });
assert.ok(c.welcome, c.error);
assert.ok(c.welcome, c.error);
await h.sleep(1400);
await h.sleep(1400);
- assert.strictEqual(lifecycle.get(ring.id).state, 'active', 'an answered call is not missed');
+ assert.strictEqual((await lifecycle.get(ring.id)).state, 'active', 'an answered call is not missed');
await bye(a, c, annChat, catChat, anon, outsider);
await bye(a, c, annChat, catChat, anon, outsider);
- ring = lifecycle.get(ring.id);
+ ring = await lifecycle.get(ring.id);
assert.deepStrictEqual([ring.state, ring.end_reason], ['ended', 'empty']);
assert.deepStrictEqual([ring.state, ring.end_reason], ['ended', 'empty']);
});
});
@@ -350,27 +350,27 @@ t('call-user: declined, busy, missed after the ring timeout (the caller hears "n
@@ -350,27 +350,27 @@ t('call-user: declined, busy, missed after the ring timeout (the caller hears "n
assert.deepStrictEqual([r.status, r.body.reusedChannel, r.body.channel.id], [200, true, annCall], 'the caller\'s channel is reused');
assert.deepStrictEqual([r.status, r.body.reusedChannel, r.body.channel.id], [200, true, annCall], 'the caller\'s channel is reused');
await api('POST', '/voice-channels/call-user/respond', dan, { caller_user_id: ann.id, channel_id: annCall, status: 'declined' });
await api('POST', '/voice-channels/call-user/respond', dan, { caller_user_id: ann.id, channel_id: annCall, status: 'declined' });
assert.strictEqual((await annChat.next((m) => m.type === 'vc-call-response')).status, 'declined');
assert.strictEqual((await annChat.next((m) => m.type === 'vc-call-response')).status, 'declined');
- const declined = rows({ kind: 'direct' })[0];
+ const declined = (await rows({ kind: 'direct' }))[0];
assert.deepStrictEqual([declined.target_user_id, declined.state, declined.end_reason], [dan.id, 'declined', 'declined']);
assert.deepStrictEqual([declined.target_user_id, declined.state, declined.end_reason], [dan.id, 'declined', 'declined']);
assert.ok(declined.ended_at);
assert.ok(declined.ended_at);
// busy
// busy
await api('POST', '/voice-channels/call-user', ann, { username: 'eve' });
await api('POST', '/voice-channels/call-user', ann, { username: 'eve' });
await api('POST', '/voice-channels/call-user/respond', eve, { caller_user_id: ann.id, channel_id: annCall, status: 'busy' });
await api('POST', '/voice-channels/call-user/respond', eve, { caller_user_id: ann.id, channel_id: annCall, status: 'busy' });
- assert.deepStrictEqual([rows({ kind: 'direct' })[0].state, rows({ kind: 'direct' })[0].end_reason], ['declined', 'busy']);
+ assert.deepStrictEqual([(await rows({ kind: 'direct' }))[0].state, (await rows({ kind: 'direct' }))[0].end_reason], ['declined', 'busy']);
// missed: bob never answers
// missed: bob never answers
r = await api('POST', '/voice-channels/call-user', ann, { username: 'bob' });
r = await api('POST', '/voice-channels/call-user', ann, { username: 'bob' });
- const ringing = rows({ kind: 'direct' })[0];
+ const ringing = (await rows({ kind: 'direct' }))[0];
assert.strictEqual(ringing.state, 'ringing');
assert.strictEqual(ringing.state, 'ringing');
// Ringing again re-rings the same call.
// Ringing again re-rings the same call.
await api('POST', '/voice-channels/call-user', ann, { username: 'bob' });
await api('POST', '/voice-channels/call-user', ann, { username: 'bob' });
- assert.strictEqual(rows({ kind: 'direct' })[0].id, ringing.id);
+ assert.strictEqual((await rows({ kind: 'direct' }))[0].id, ringing.id);
const noAnswer = await annChat.next((m) => m.type === 'vc-call-response' && m.status === 'no-answer', 4000);
const noAnswer = await annChat.next((m) => m.type === 'vc-call-response' && m.status === 'no-answer', 4000);
assert.deepStrictEqual([noAnswer.fromUserId, noAnswer.fromUsername, noAnswer.channelId], [bob.id, 'bob', annCall]);
assert.deepStrictEqual([noAnswer.fromUserId, noAnswer.fromUsername, noAnswer.channelId], [bob.id, 'bob', annCall]);
- const missed = lifecycle.get(ringing.id);
+ const missed = await lifecycle.get(ringing.id);
assert.deepStrictEqual([missed.state, missed.end_reason], ['missed', 'timeout']);
assert.deepStrictEqual([missed.state, missed.end_reason], ['missed', 'timeout']);
// bob's late "accepted" finds no ring to answer (the relay still goes, as on Live)
// bob's late "accepted" finds no ring to answer (the relay still goes, as on Live)
assert.strictEqual((await api('POST', '/voice-channels/call-user/respond', bob, { caller_user_id: ann.id, channel_id: annCall, status: 'accepted' })).status, 200);
assert.strictEqual((await api('POST', '/voice-channels/call-user/respond', bob, { caller_user_id: ann.id, channel_id: annCall, status: 'accepted' })).status, 200);
- assert.strictEqual(lifecycle.get(ringing.id).state, 'missed');
+ assert.strictEqual((await lifecycle.get(ringing.id)).state, 'missed');
// Six rings a minute (ann has used five).
// Six rings a minute (ann has used five).
await api('POST', '/voice-channels/call-user', ann, { username: 'kay' });
await api('POST', '/voice-channels/call-user', ann, { username: 'kay' });
r = await api('POST', '/voice-channels/call-user', ann, { username: 'kay' });
r = await api('POST', '/voice-channels/call-user', ann, { username: 'kay' });
@@ -387,13 +387,13 @@ t('call-user: an invite that cannot be delivered is failed, with the reason', as
@@ -387,13 +387,13 @@ t('call-user: an invite that cannot be delivered is failed, with the reason', as
let r;
let r;
try { r = await api('POST', '/voice-channels/call-user', eve, { username: 'dan' }); } finally { h.chatServer.sendDm = orig; }
try { r = await api('POST', '/voice-channels/call-user', eve, { username: 'dan' }); } finally { h.chatServer.sendDm = orig; }
assert.deepStrictEqual([r.status, r.body.error], [500, 'Failed to call user']);
assert.deepStrictEqual([r.status, r.body.error], [500, 'Failed to call user']);
- const row = rows({ kind: 'direct' })[0];
+ const row = (await rows({ kind: 'direct' }))[0];
assert.deepStrictEqual([row.created_by, row.state, row.end_reason], [eve.id, 'failed', 'invite_failed: socket layer down']);
assert.deepStrictEqual([row.created_by, row.state, row.end_reason], [eve.id, 'failed', 'invite_failed: socket layer down']);
// Deleting a channel ends the rings still waiting on it (canceled).
// Deleting a channel ends the rings still waiting on it (canceled).
r = await api('POST', '/voice-channels/call-user', eve, { username: 'dan' });
r = await api('POST', '/voice-channels/call-user', eve, { username: 'dan' });
- assert.strictEqual(rows({ kind: 'direct' })[0].state, 'ringing');
+ assert.strictEqual((await rows({ kind: 'direct' }))[0].state, 'ringing');
await api('DELETE', `/voice-channels/${r.body.channel.id}`, eve);
await api('DELETE', `/voice-channels/${r.body.channel.id}`, eve);
- assert.deepStrictEqual([rows({ kind: 'direct' })[0].state, rows({ kind: 'direct' })[0].end_reason], ['ended', 'canceled']);
+ assert.deepStrictEqual([(await rows({ kind: 'direct' }))[0].state, (await rows({ kind: 'direct' }))[0].end_reason], ['ended', 'canceled']);
});
});
t('stream channel through /internal/calls: guard, create, join (legacy id), mode change, removal', async () => {
t('stream channel through /internal/calls: guard, create, join (legacy id), mode change, removal', async () => {
@@ -420,7 +420,7 @@ t('stream channel through /internal/calls: guard, create, join (legacy id), mode
@@ -420,7 +420,7 @@ t('stream channel through /internal/calls: guard, create, join (legacy id), mode
assert.strictEqual(host.welcome.participants.find((p) => p.username === 'streamy').isStreamer, true);
assert.strictEqual(host.welcome.participants.find((p) => p.username === 'streamy').isStreamer, true);
r = await api('GET', `/${liveStream}/call`, null);
r = await api('GET', `/${liveStream}/call`, null);
assert.strictEqual(r.body.participant_count, 2);
assert.strictEqual(r.body.participant_count, 2);
- let session = rows({ channelId: `stream-${liveStream}` })[0];
+ let session = (await rows({ channelId: `stream-${liveStream}` }))[0];
assert.deepStrictEqual([session.kind, session.state, session.stream_id, session.created_by], ['stream', 'active', liveStream, streamer.id]);
assert.deepStrictEqual([session.kind, session.state, session.stream_id, session.created_by], ['stream', 'active', liveStream, streamer.id]);
// A new mode ends the call in progress (Live's createStreamChannel).
// A new mode ends the call in progress (Live's createStreamChannel).
@@ -428,16 +428,16 @@ t('stream channel through /internal/calls: guard, create, join (legacy id), mode
@@ -428,16 +428,16 @@ t('stream channel through /internal/calls: guard, create, join (legacy id), mode
assert.strictEqual(r.body.channel.mode, 'cam+mic');
assert.strictEqual(r.body.channel.mode, 'cam+mic');
await viewer.next((m) => m.type === 'call-ended');
await viewer.next((m) => m.type === 'call-ended');
await host.next((m) => m.type === 'call-ended');
await host.next((m) => m.type === 'call-ended');
- assert.deepStrictEqual([lifecycle.get(session.id).state, lifecycle.get(session.id).end_reason], ['ended', 'ended']);
+ assert.deepStrictEqual([(await lifecycle.get(session.id)).state, (await lifecycle.get(session.id)).end_reason], ['ended', 'ended']);
await bye(viewer, host);
await bye(viewer, host);
const v2 = await join({ channelId: `stream-${liveStream}`, token: bob.token, ip: '198.51.100.50' });
const v2 = await join({ channelId: `stream-${liveStream}`, token: bob.token, ip: '198.51.100.50' });
assert.strictEqual(v2.welcome.callMode, 'cam+mic');
assert.strictEqual(v2.welcome.callMode, 'cam+mic');
- session = rows({ channelId: `stream-${liveStream}` })[0];
+ session = (await rows({ channelId: `stream-${liveStream}` }))[0];
r = await h.http('DELETE', `/internal/calls/stream-channel/${liveStream}`, { token });
r = await h.http('DELETE', `/internal/calls/stream-channel/${liveStream}`, { token });
assert.deepStrictEqual(r.body, { ok: true, removed: true });
assert.deepStrictEqual(r.body, { ok: true, removed: true });
await v2.next((m) => m.type === 'call-ended');
await v2.next((m) => m.type === 'call-ended');
- assert.deepStrictEqual([lifecycle.get(session.id).state, lifecycle.get(session.id).end_reason], ['ended', 'stream_ended']);
+ assert.deepStrictEqual([(await lifecycle.get(session.id)).state, (await lifecycle.get(session.id)).end_reason], ['ended', 'stream_ended']);
assert.deepStrictEqual((await api('GET', `/${liveStream}/call`, null)).body, { call_mode: null, channelId: null, participants: [], participant_count: 0 });
assert.deepStrictEqual((await api('GET', `/${liveStream}/call`, null)).body, { call_mode: null, channelId: null, participants: [], participant_count: 0 });
assert.deepStrictEqual((await h.http('DELETE', `/internal/calls/stream-channel/${liveStream}`, { token })).body, { ok: true, removed: false });
assert.deepStrictEqual((await h.http('DELETE', `/internal/calls/stream-channel/${liveStream}`, { token })).body, { ok: true, removed: false });
const gone = await join({ channelId: `stream-${liveStream}`, token: bob.token, ip: '198.51.100.50' });
const gone = await join({ channelId: `stream-${liveStream}`, token: bob.token, ip: '198.51.100.50' });
@@ -467,29 +467,29 @@ t('PUT /api/streams/:id/call: the streamer only, a live stream only, a known mod
@@ -467,29 +467,29 @@ t('PUT /api/streams/:id/call: the streamer only, a live stream only, a known mod
});
});
t('lifecycle: transitions are one-way; a restart closes what was left open', async () => {
t('lifecycle: transitions are one-way; a restart closes what was left open', async () => {
- const d = lifecycle.openDirect({ callerId: ann.id, targetId: kay.id, channelId: 'user-x' });
+ const d = await lifecycle.openDirect({ callerId: ann.id, targetId: kay.id, channelId: 'user-x' });
assert.strictEqual(d.state, 'pending');
assert.strictEqual(d.state, 'pending');
- assert.strictEqual(lifecycle.transition(d.id, 'active'), null, 'pending cannot become active without ringing');
- assert.strictEqual(lifecycle.transition(d.id, 'declined'), null);
- lifecycle.ring(d.id, { timeoutMs: 60000 });
- const declined = lifecycle.respond({ callerId: ann.id, targetId: kay.id, channelId: 'user-x', status: 'declined' });
+ assert.strictEqual(await lifecycle.transition(d.id, 'active'), null, 'pending cannot become active without ringing');
+ assert.strictEqual(await lifecycle.transition(d.id, 'declined'), null);
+ await lifecycle.ring(d.id, { timeoutMs: 60000 });
+ const declined = await lifecycle.respond({ callerId: ann.id, targetId: kay.id, channelId: 'user-x', status: 'declined' });
assert.strictEqual(declined.state, 'declined');
assert.strictEqual(declined.state, 'declined');
- assert.strictEqual(lifecycle.transition(d.id, 'active'), null, 'a final state stays final');
- assert.strictEqual(lifecycle.respond({ callerId: ann.id, targetId: kay.id, channelId: 'user-x', status: 'accepted' }), null);
-
- const pending = lifecycle.openDirect({ callerId: bob.id, targetId: kay.id, channelId: 'user-y' });
- const ringing = lifecycle.ring(lifecycle.openDirect({ callerId: cat.id, targetId: kay.id, channelId: 'user-z' }).id, { timeoutMs: 60000 });
- const active = lifecycle.sessionStarted({ channelId: 'user-z' });
- assert.strictEqual(lifecycle.sessionStarted({ channelId: 'user-z' }).id, active.id, 'one session per channel');
- const out = lifecycle.recover();
+ assert.strictEqual(await lifecycle.transition(d.id, 'active'), null, 'a final state stays final');
+ assert.strictEqual(await lifecycle.respond({ callerId: ann.id, targetId: kay.id, channelId: 'user-x', status: 'accepted' }), null);
+
+ const pending = await lifecycle.openDirect({ callerId: bob.id, targetId: kay.id, channelId: 'user-y' });
+ const ringing = await lifecycle.ring((await lifecycle.openDirect({ callerId: cat.id, targetId: kay.id, channelId: 'user-z' })).id, { timeoutMs: 60000 });
+ const active = await lifecycle.sessionStarted({ channelId: 'user-z' });
+ assert.strictEqual((await lifecycle.sessionStarted({ channelId: 'user-z' })).id, active.id, 'one session per channel');
+ const out = await lifecycle.recover();
assert.ok(out.failed >= 1 && out.missed >= 1 && out.ended >= 1, JSON.stringify(out));
assert.ok(out.failed >= 1 && out.missed >= 1 && out.ended >= 1, JSON.stringify(out));
- assert.deepStrictEqual([lifecycle.get(pending.id).state, lifecycle.get(pending.id).end_reason], ['failed', 'restart']);
- assert.deepStrictEqual([lifecycle.get(ringing.id).state, lifecycle.get(ringing.id).end_reason], ['missed', 'restart']);
- assert.deepStrictEqual([lifecycle.get(active.id).state, lifecycle.get(active.id).end_reason], ['ended', 'restart']);
- assert.strictEqual(h.db.get("SELECT COUNT(*) AS n FROM calls WHERE state IN ('pending', 'ringing', 'active')").n, 0);
+ assert.deepStrictEqual([(await lifecycle.get(pending.id)).state, (await lifecycle.get(pending.id)).end_reason], ['failed', 'restart']);
+ assert.deepStrictEqual([(await lifecycle.get(ringing.id)).state, (await lifecycle.get(ringing.id)).end_reason], ['missed', 'restart']);
+ assert.deepStrictEqual([(await lifecycle.get(active.id)).state, (await lifecycle.get(active.id)).end_reason], ['ended', 'restart']);
+ assert.strictEqual((await h.db.get("SELECT COUNT(*) AS n FROM calls WHERE state IN ('pending', 'ringing', 'active')")).n, 0);
});
});
t.run(async () => {
t.run(async () => {
- try { if (callServer) callServer.close(); } catch { /* */ }
+ try { if (callServer) await callServer.close(); } catch { /* */ }
if (h && h.close) await h.close();
if (h && h.close) await h.close();
});
});
@@ -12,14 +12,14 @@ const { boot, suite, sqliteNow } = require('./helpers');
@@ -12,14 +12,14 @@ const { boot, suite, sqliteNow } = require('./helpers');
const t = suite('chat-ai');
const t = suite('chat-ai');
let h, db, ctx, chatAi, aiClient, streamer, alice, bob, channelId, streamId;
let h, db, ctx, chatAi, aiClient, streamer, alice, bob, channelId, streamId;
-function msg(o = {}) {
- return db.run(
+async function msg(o = {}) {
+ return (await db.run(
`INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, channel_user_id, timestamp)
`INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, channel_user_id, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[o.stream_id === undefined ? streamId : o.stream_id, o.user_id === undefined ? null : o.user_id,
[o.stream_id === undefined ? streamId : o.stream_id, o.user_id === undefined ? null : o.user_id,
o.username || null, o.message || 'a message', o.message_type || 'chat', o.is_global ? 1 : 0,
o.username || null, o.message || 'a message', o.message_type || 'chat', o.is_global ? 1 : 0,
o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow()]
o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow()]
- ).lastInsertRowid;
+ )).lastInsertRowid;
}
}
t('boot', async () => {
t('boot', async () => {
@@ -50,8 +50,8 @@ t('the job selects Chat messages and writes the summary', async () => {
@@ -50,8 +50,8 @@ t('the job selects Chat messages and writes the summary', async () => {
},
},
},
},
});
});
- msg({ user_id: alice.id, username: 'alice', message: 'hello world' });
- msg({ user_id: bob.id, username: 'bob', message: 'a big donation', message_type: 'donation' });
+ await msg({ user_id: alice.id, username: 'alice', message: 'hello world' });
+ await msg({ user_id: bob.id, username: 'bob', message: 'a big donation', message_type: 'donation' });
await chatAi._tick();
await chatAi._tick();
@@ -60,23 +60,23 @@ t('the job selects Chat messages and writes the summary', async () => {
@@ -60,23 +60,23 @@ t('the job selects Chat messages and writes the summary', async () => {
assert.strictEqual(global.input.messages.length, 2);
assert.strictEqual(global.input.messages.length, 2);
assert.strictEqual(global.input.messages[0].author, 'alice');
assert.strictEqual(global.input.messages[0].author, 'alice');
- const row = db.getChatAiSummary('global', 0, 'global');
+ const row = await db.getChatAiSummary('global', 0, 'global');
assert.ok(row, 'a global summary row was written');
assert.ok(row, 'a global summary row was written');
assert.strictEqual(row.overview, 'the room talked about games');
assert.strictEqual(row.overview, 'the room talked about games');
assert.strictEqual(row.memory_json, 'memory line');
assert.strictEqual(row.memory_json, 'memory line');
assert.ok(JSON.parse(row.timeline_json).some((x) => x.label === 'a moment'));
assert.ok(JSON.parse(row.timeline_json).some((x) => x.label === 'a moment'));
- const events = db.getChatTimelineEvents({ scope: 'global', subjectId: 0, limit: 10 });
+ const events = await db.getChatTimelineEvents({ scope: 'global', subjectId: 0, limit: 10 });
assert.ok(events.some((e) => e.label === 'a moment'), 'the timeline event was appended');
assert.ok(events.some((e) => e.label === 'a moment'), 'the timeline event was appended');
});
});
t('falls back to the extractive summary when AI does not answer', async () => {
t('falls back to the extractive summary when AI does not answer', async () => {
aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed' } }) } });
aiClient._setClient({ runs: { create: async () => ({ run: { status: 'failed' } }) } });
- db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-40 * 60 * 1000)]);
- msg({ user_id: alice.id, username: 'alice', message: 'the extractive line' });
+ await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-40 * 60 * 1000)]);
+ await msg({ user_id: alice.id, username: 'alice', message: 'the extractive line' });
await chatAi._tick();
await chatAi._tick();
- const row = db.getChatAiSummary('global', 0, 'global');
+ const row = await db.getChatAiSummary('global', 0, 'global');
assert.ok(/messages from \d+ chatter/.test(row.overview), row.overview);
assert.ok(/messages from \d+ chatter/.test(row.overview), row.overview);
assert.ok(row.overview.includes('the extractive line'), row.overview);
assert.ok(row.overview.includes('the extractive line'), row.overview);
aiClient._reset();
aiClient._reset();
@@ -86,32 +86,32 @@ t('falls back to the extractive summary when AI does not answer', async () => {
@@ -86,32 +86,32 @@ t('falls back to the extractive summary when AI does not answer', async () => {
t('the job is off unless CHAT_AI_ENABLED is set', async () => {
t('the job is off unless CHAT_AI_ENABLED is set', async () => {
const cfg = require('../server/config');
const cfg = require('../server/config');
assert.strictEqual(cfg.ai.enabled, true, 'this boot enabled it');
assert.strictEqual(cfg.ai.enabled, true, 'this boot enabled it');
- const before = db.getChatAiSummary('global', 0, 'global').message_count;
+ const before = (await db.getChatAiSummary('global', 0, 'global')).message_count;
cfg.ai.enabled = false;
cfg.ai.enabled = false;
- msg({ user_id: bob.id, username: 'bob', message: 'while the job is off' });
+ await msg({ user_id: bob.id, username: 'bob', message: 'while the job is off' });
await chatAi._tick();
await chatAi._tick();
- assert.strictEqual(db.getChatAiSummary('global', 0, 'global').message_count, before);
+ assert.strictEqual((await db.getChatAiSummary('global', 0, 'global')).message_count, before);
cfg.ai.enabled = true;
cfg.ai.enabled = true;
});
});
t('the six routes answer with Live\'s shapes, publicly', async () => {
t('the six routes answer with Live\'s shapes, publicly', async () => {
- db.upsertChatAiSummary({
+ await db.upsertChatAiSummary({
scope: 'global', subject_id: 0, window: 'global', overview: 'everything happened', memory_json: 'mem',
scope: 'global', subject_id: 0, window: 'global', overview: 'everything happened', memory_json: 'mem',
timeline_json: JSON.stringify([{ ts: '2026-01-01 00:00:00', label: 'moment', detail: 'detail' }]),
timeline_json: JSON.stringify([{ ts: '2026-01-01 00:00:00', label: 'moment', detail: 'detail' }]),
message_count: 5, window_message_count: 4, window_label: 'past hour',
message_count: 5, window_message_count: 4, window_label: 'past hour',
});
});
- db.upsertChatAiSummary({
+ await db.upsertChatAiSummary({
scope: 'user', subject_id: alice.id, window: 'rolling',
scope: 'user', subject_id: alice.id, window: 'rolling',
overview: JSON.stringify({ today: 'today read', alltime: 'all time read', has_24h: true }),
overview: JSON.stringify({ today: 'today read', alltime: 'all time read', has_24h: true }),
timeline_json: '[]', message_count: 9,
timeline_json: '[]', message_count: 9,
});
});
- db.upsertChatAiSummary({
+ await db.upsertChatAiSummary({
scope: 'anon', subject_id: 7, window: 'rolling',
scope: 'anon', subject_id: 7, window: 'rolling',
overview: JSON.stringify({ today: 'anon today', alltime: 'anon all', has_24h: false }), timeline_json: '[]', message_count: 2,
overview: JSON.stringify({ today: 'anon today', alltime: 'anon all', has_24h: false }), timeline_json: '[]', message_count: 2,
});
});
- db.recordRelayUser('twitch', 'SomeOne');
- const ru = db.getRelayUser('twitch', 'someone');
- db.upsertChatAiSummary({
+ await db.recordRelayUser('twitch', 'SomeOne');
+ const ru = await db.getRelayUser('twitch', 'someone');
+ await db.upsertChatAiSummary({
scope: 'relay', subject_id: ru.id, window: 'rolling',
scope: 'relay', subject_id: ru.id, window: 'rolling',
overview: JSON.stringify({ today: 'relay today', alltime: 'relay all', has_24h: true }), timeline_json: '[]', message_count: 3,
overview: JSON.stringify({ today: 'relay today', alltime: 'relay all', has_24h: true }), timeline_json: '[]', message_count: 3,
});
});
@@ -70,10 +70,10 @@ t('internal read API: settings + moderator_ids validate, defaults for a channel
@@ -70,10 +70,10 @@ t('internal read API: settings + moderator_ids validate, defaults for a channel
});
});
t('internal read API: moderator_ids oldest first; the channels a user moderates validate', async () => {
t('internal read API: moderator_ids oldest first; the channels a user moderates validate', async () => {
- h.db.addChannelModerator(channelId, mod.id, streamer.id);
- h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);
- h.db.addChannelModerator(channelId, stranger.id, streamer.id);
- h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-02-01 00:00:00', stranger.id]);
+ await h.db.addChannelModerator(channelId, mod.id, streamer.id);
+ await h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-01-01 00:00:00', mod.id]);
+ await h.db.addChannelModerator(channelId, stranger.id, streamer.id);
+ await h.db.run('UPDATE channel_moderators SET created_at = ? WHERE user_id = ?', ['2026-02-01 00:00:00', stranger.id]);
const r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: RO });
const r = await h.http('GET', `/internal/moderation/channels/${channelId}`, { token: RO });
checkContract('chat.channel-moderation-result', r.body);
checkContract('chat.channel-moderation-result', r.body);
assert.deepStrictEqual(r.body.moderator_ids, [mod.id, stranger.id]);
assert.deepStrictEqual(r.body.moderator_ids, [mod.id, stranger.id]);
@@ -118,7 +118,7 @@ t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap',
@@ -118,7 +118,7 @@ t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap',
assert.strictEqual(up.status, 200, JSON.stringify(up.body));
assert.strictEqual(up.status, 200, JSON.stringify(up.body));
assert.strictEqual(up.body.emote.code, 'hype');
assert.strictEqual(up.body.emote.code, 'hype');
assert.strictEqual(up.body.emote.url, 'https://openvibe.media/chat/emote.png');
assert.strictEqual(up.body.emote.url, 'https://openvibe.media/chat/emote.png');
- const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);
+ const row = await h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);
assert.strictEqual(row.media_asset_id, 777);
assert.strictEqual(row.media_asset_id, 777);
assert.strictEqual(row.media_url, 'https://openvibe.media/chat/emote.png');
assert.strictEqual(row.media_url, 'https://openvibe.media/chat/emote.png');
assert.strictEqual(row.size, 150);
assert.strictEqual(row.size, 150);
@@ -155,19 +155,19 @@ t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap',
@@ -155,19 +155,19 @@ t('emote API: create uploads to Media, lists in Live\'s shapes, clash and cap',
});
});
t('emote API: rename/resize, delete removes the Media object', async () => {
t('emote API: rename/resize, delete removes the Media object', async () => {
- const row = h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);
+ const row = await h.db.get('SELECT * FROM emotes WHERE code = ?', ['hype']);
let r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: streamer.token, body: { code: 'hype2', size: 300 } });
let r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: streamer.token, body: { code: 'hype2', size: 300 } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.deepStrictEqual([r.body.code, r.body.size], ['hype2', 200], 'size clamped to the channel max (200)');
assert.deepStrictEqual([r.body.code, r.body.size], ['hype2', 200], 'size clamped to the channel max (200)');
r = await h.http('DELETE', `/api/emotes/${row.id}`, { token: streamer.token });
r = await h.http('DELETE', `/api/emotes/${row.id}`, { token: streamer.token });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM emotes WHERE id = ?', [row.id]).n, 0);
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM emotes WHERE id = ?', [row.id])).n, 0);
assert.deepStrictEqual(deletedAssets, [777], 'the Media object is removed');
assert.deepStrictEqual(deletedAssets, [777], 'the Media object is removed');
});
});
t('emote API: not-mine is refused, sources round-trip through the Live-context effect', async () => {
t('emote API: not-mine is refused, sources round-trip through the Live-context effect', async () => {
- const row = h.db.get('SELECT id FROM emotes WHERE code = ?', ['two']);
+ const row = await h.db.get('SELECT id FROM emotes WHERE code = ?', ['two']);
const r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: other.token, body: { code: 'nope' } });
const r = await h.http('PATCH', `/api/emotes/${row.id}`, { token: other.token, body: { code: 'nope' } });
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.status, 403, r.text);
@@ -212,7 +212,7 @@ t('moderators: list is public to any signed-in reader; moderation settings are m
@@ -212,7 +212,7 @@ t('moderators: list is public to any signed-in reader; moderation settings are m
});
});
t('moderation settings: owner writes, a mod cannot change policy keys', async () => {
t('moderation settings: owner writes, a mod cannot change policy keys', async () => {
- h.db.addChannelModerator(channelId, mod.id, streamer.id);
+ await h.db.addChannelModerator(channelId, mod.id, streamer.id);
let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });
let r = await h.http('PUT', `/api/chat/channels/${channelId}/moderation`, { token: streamer.token, body: { slow_mode_seconds: 12, ip_approval_mode: 1 } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.body.settings.slow_mode_seconds, 12);
assert.strictEqual(r.body.settings.slow_mode_seconds, 12);
@@ -239,7 +239,7 @@ t('moderation settings: owner writes, a mod cannot change policy keys', async ()
@@ -239,7 +239,7 @@ t('moderation settings: owner writes, a mod cannot change policy keys', async ()
t('alert op: Chat resolves the sound from its own row and broadcasts it', async () => {
t('alert op: Chat resolves the sound from its own row and broadcasts it', async () => {
const snd = path.join(h.tmp, 'sounds', 'donation.mp3');
const snd = path.join(h.tmp, 'sounds', 'donation.mp3');
fs.writeFileSync(snd, 'ID3alert');
fs.writeFileSync(snd, 'ID3alert');
- h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');
+ await h.db.setChannelAlertSound(channelId, 'donation', snd, 'audio/mpeg');
const ws = await h.ws({ ip: '198.51.100.7', stream: streamId });
const ws = await h.ws({ ip: '198.51.100.7', stream: streamId });
ws.sendJson({ type: 'join', streamId, channelUserId: streamer.id, anonId: null });
ws.sendJson({ type: 'join', streamId, channelUserId: streamer.id, anonId: null });
@@ -21,15 +21,15 @@ const PRIVATE = [/\/messages/, /\/settings/, /\/auth\//, /\/api\//, /\/internal\
@@ -21,15 +21,15 @@ const PRIVATE = [/\/messages/, /\/settings/, /\/auth\//, /\/api\//, /\/internal\
t('boot', async () => {
t('boot', async () => {
h = await boot({ env: { CHAT_WEB_URL: SITE } });
h = await boot({ env: { CHAT_WEB_URL: SITE } });
alice = h.addUser('alice', { subject: ids.newId('user') });
alice = h.addUser('alice', { subject: ids.newId('user') });
- h.ctx.upsertUser(h.live.users.get(alice.id));
+ await h.ctx.upsertUser(h.live.users.get(alice.id));
const rooms = require('../server/rooms/rooms');
const rooms = require('../server/rooms/rooms');
- rooms.create(alice, { name: 'Night Owls', topic: 'Late chats' });
- rooms.create(alice, { name: 'Closed Room', slug: 'private-room', visibility: 'private' });
+ await rooms.create(alice, { name: 'Night Owls', topic: 'Late chats' });
+ await rooms.create(alice, { name: 'Closed Room', slug: 'private-room', visibility: 'private' });
await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'hello from the test' } });
await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'hello from the test' } });
// Backdate the message so "lastmod is the data's own day" is checkable against "not today".
// Backdate the message so "lastmod is the data's own day" is checkable against "not today".
const db = require('../server/db/database');
const db = require('../server/db/database');
- db.run("UPDATE chat_messages SET timestamp = '2026-01-02 03:04:05' WHERE id = (SELECT MAX(id) FROM chat_messages)");
- h.db.run("UPDATE rooms SET last_message_at = '2026-02-03 04:05:06' WHERE slug = 'night-owls'");
+ await db.run("UPDATE chat_messages SET timestamp = '2026-01-02 03:04:05' WHERE id = (SELECT MAX(id) FROM chat_messages)");
+ await h.db.run("UPDATE rooms SET last_message_at = '2026-02-03 04:05:06' WHERE slug = 'night-owls'");
require('../server/chat/history-store').invalidate('global');
require('../server/chat/history-store').invalidate('global');
});
});
@@ -31,7 +31,7 @@ t('boot', async () => {
@@ -31,7 +31,7 @@ t('boot', async () => {
alice = h.addUser('alice', { subject: ids.newId('user') });
alice = h.addUser('alice', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
carol = h.addUser('carol', { subject: ids.newId('user') });
carol = h.addUser('carol', { subject: ids.newId('user') });
- for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
+ for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
});
});
t('the inbox: your conversations only, unread counts in the list and the navigation', async () => {
t('the inbox: your conversations only, unread counts in the list and the navigation', async () => {
@@ -75,7 +75,7 @@ t('participants only: a guessed conversation looks like no conversation', async
@@ -75,7 +75,7 @@ t('participants only: a guessed conversation looks like no conversation', async
assert.strictEqual((await dmApi('GET', `/conversations/${conv}`, carol)).status, 403);
assert.strictEqual((await dmApi('GET', `/conversations/${conv}`, carol)).status, 403);
const r = await req('POST', `/messages/${conv}`, form(carol, { message: 'let me in' }));
const r = await req('POST', `/messages/${conv}`, form(carol, { message: 'let me in' }));
assert.match(r.headers.get('location'), /error=/);
assert.match(r.headers.get('location'), /error=/);
- assert.ok(!h.db.all('SELECT message FROM dm_messages WHERE conversation_id = ?', [conv]).some((m) => m.message === 'let me in'));
+ assert.ok(!(await h.db.all('SELECT message FROM dm_messages WHERE conversation_id = ?', [conv])).some((m) => m.message === 'let me in'));
assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(carol, {}))).headers.get('location'), '/messages', 'nor block from it');
assert.strictEqual((await req('POST', `/messages/${conv}/block`, form(carol, {}))).headers.get('location'), '/messages', 'nor block from it');
});
});
@@ -122,7 +122,7 @@ t('groups have no one to block', async () => {
@@ -122,7 +122,7 @@ t('groups have no one to block', async () => {
const page = await req('GET', `/messages/${gid}`, { headers: cookie(alice) });
const page = await req('GET', `/messages/${gid}`, { headers: cookie(alice) });
assert.ok(!/Block /.test(page.text));
assert.ok(!/Block /.test(page.text));
assert.match((await req('POST', `/messages/${gid}/block`, form(alice, {}))).headers.get('location'), new RegExp(`^/messages/${gid}\\?error=`));
assert.match((await req('POST', `/messages/${gid}/block`, form(alice, {}))).headers.get('location'), new RegExp(`^/messages/${gid}\\?error=`));
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM dm_blocks WHERE blocker_id = ?', [alice.id]).n, 0);
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM dm_blocks WHERE blocker_id = ?', [alice.id])).n, 0);
});
});
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });
@@ -105,10 +105,10 @@ function deliver(event, { secret = SECRET, now = Date.now(), headers = {}, strip
@@ -105,10 +105,10 @@ function deliver(event, { secret = SECRET, now = Date.now(), headers = {}, strip
return h.http('POST', '/internal/events', { raw, headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Event-Id': event.event_id, ...signed, ...headers } });
return h.http('POST', '/internal/events', { raw, headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Event-Id': event.event_id, ...signed, ...headers } });
}
}
const bridge = (commits) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'live-boot-1', ops: [{ seq: ++n, op: 'deployNotice', args: [commits] }] } });
const bridge = (commits) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: 'live-boot-1', ops: [{ seq: ++n, op: 'deployNotice', args: [commits] }] } });
-const cards = () => h.db.all("SELECT id, metadata FROM chat_messages WHERE message_type = 'system' AND metadata LIKE '%\"kind\":\"deploy\"%' ORDER BY id").map((r) => ({ id: r.id, ...JSON.parse(r.metadata) }));
-const card = (id) => cards().find((c) => c.id === id);
-const speak = (text) => h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: text, message_type: 'chat', is_global: false });
-const releaseRow = (head) => h.db.get('SELECT * FROM deploy_releases WHERE head = ?', [head]);
+const cards = async () => (await h.db.all("SELECT id, metadata FROM chat_messages WHERE message_type = 'system' AND metadata LIKE '%\"kind\":\"deploy\"%' ORDER BY id")).map((r) => ({ id: r.id, ...JSON.parse(r.metadata) }));
+const card = async (id) => (await cards()).find((c) => c.id === id);
+const speak = async (text) => await h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: viewer.username, message: text, message_type: 'chat', is_global: false });
+const releaseRow = async (head) => await h.db.get('SELECT * FROM deploy_releases WHERE head = ?', [head]);
t('boot with the consumer on and a stub Events', async () => {
t('boot with the consumer on and a stub Events', async () => {
const eventsPort = await new Promise((r) => eventsServer.listen(0, '127.0.0.1', () => r(eventsServer.address().port)));
const eventsPort = await new Promise((r) => eventsServer.listen(0, '127.0.0.1', () => r(eventsServer.address().port)));
@@ -168,8 +168,8 @@ t('signature v2 only: bad signature, stale or future timestamp, v1-only, unsigne
@@ -168,8 +168,8 @@ t('signature v2 only: bad signature, stale or future timestamp, v1-only, unsigne
// A v2 header that names another time than X-OpenVibe-Timestamp.
// A v2 header that names another time than X-OpenVibe-Timestamp.
r = await deliver(ev, { headers: { 'X-OpenVibe-Timestamp': String(Math.floor(Date.now() / 1000) - 5) } });
r = await deliver(ev, { headers: { 'X-OpenVibe-Timestamp': String(Math.floor(Date.now() / 1000) - 5) } });
assert.strictEqual(r.status, 401);
assert.strictEqual(r.status, 401);
- assert.strictEqual(cards().length, 0, 'nothing was applied');
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n, 0);
+ assert.strictEqual((await cards()).length, 0, 'nothing was applied');
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n, 0);
// Signed but not an envelope.
// Signed but not an envelope.
r = await deliver({ event_type: 'live.release.deployed' });
r = await deliver({ event_type: 'live.release.deployed' });
assert.strictEqual(r.status, 400);
assert.strictEqual(r.status, 400);
@@ -198,27 +198,27 @@ t('no CHAT_EVENTS_SECRET: the route answers 503', async () => {
@@ -198,27 +198,27 @@ t('no CHAT_EVENTS_SECRET: the route answers 503', async () => {
});
});
t('live.release.deployed makes the deploy card; the same event again is one card (inbox)', async () => {
t('live.release.deployed makes the deploy card; the same event again is one card (inbox)', async () => {
- speak('before the first card');
+ await speak('before the first card');
const ev = release([commit('a', 'events: first'), commit('b', 'events: second')]);
const ev = release([commit('a', 'events: first'), commit('b', 'events: second')]);
let r = await deliver(ev);
let r = await deliver(ev);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.deepStrictEqual([r.body.duplicate, r.body.outcome], [false, 'announced']);
assert.deepStrictEqual([r.body.duplicate, r.body.outcome], [false, 'announced']);
const id = r.body.detail.message_id;
const id = r.body.detail.message_id;
- const c = card(id);
+ const c = await card(id);
assert.deepStrictEqual(c.commits.map((x) => x.subject), ['events: first', 'events: second']);
assert.deepStrictEqual(c.commits.map((x) => x.subject), ['events: first', 'events: second']);
assert.strictEqual(c.deploys, 1);
assert.strictEqual(c.deploys, 1);
- const row = h.db.getChatMessageById(id);
+ const row = await h.db.getChatMessageById(id);
assert.strictEqual(row.message_type, 'system');
assert.strictEqual(row.message_type, 'system');
assert.strictEqual(row.is_global, 1);
assert.strictEqual(row.is_global, 1);
assert.strictEqual(row.username, 'OpenVibe.Live');
assert.strictEqual(row.username, 'OpenVibe.Live');
assert.strictEqual(row.message, '🚀 2 updates shipped: events: first · events: second');
assert.strictEqual(row.message, '🚀 2 updates shipped: events: first · events: second');
- const before = cards().length;
+ const before = (await cards()).length;
r = await deliver(ev); // Events redelivers (a lost acknowledgement)
r = await deliver(ev); // Events redelivers (a lost acknowledgement)
assert.deepStrictEqual([r.status, r.body.duplicate, r.body.outcome], [200, true, null]);
assert.deepStrictEqual([r.status, r.body.duplicate, r.body.outcome], [200, true, null]);
r = await deliver(ev, { now: Date.now() + 1000 }); // a later attempt, new signature
r = await deliver(ev, { now: Date.now() + 1000 }); // a later attempt, new signature
assert.strictEqual(r.body.duplicate, true);
assert.strictEqual(r.body.duplicate, true);
- assert.strictEqual(cards().length, before, 'still one card');
- assert.strictEqual(card(id).deploys, 1, 'not folded into itself');
+ assert.strictEqual((await cards()).length, before, 'still one card');
+ assert.strictEqual((await card(id)).deploys, 1, 'not folded into itself');
// Late joiners get this announcement once, keyed by the row id (as with the bridge).
// Late joiners get this announcement once, keyed by the row id (as with the bridge).
const late = await h.ws({ ip: '198.51.100.70' });
const late = await h.ws({ ip: '198.51.100.70' });
late.sendJson({ type: 'join' });
late.sendJson({ type: 'join' });
@@ -226,47 +226,47 @@ t('live.release.deployed makes the deploy card; the same event again is one card
@@ -226,47 +226,47 @@ t('live.release.deployed makes the deploy card; the same event again is one card
assert.strictEqual(upd.id, id);
assert.strictEqual(upd.id, id);
assert.deepStrictEqual(upd.fresh, [commit('a').hash, commit('b').hash]);
assert.deepStrictEqual(upd.fresh, [commit('a').hash, commit('b').hash]);
late.close();
late.close();
- const rel = releaseRow(commit('a').hash);
+ const rel = await releaseRow(commit('a').hash);
assert.deepStrictEqual([rel.first_via, rel.message_id, rel.event_id, rel.bridge_at], ['events', id, ev.event_id, null]);
assert.deepStrictEqual([rel.first_via, rel.message_id, rel.event_id, rel.bridge_at], ['events', id, ev.event_id, null]);
});
});
t('bridge first, then the event for the same head: one card', async () => {
t('bridge first, then the event for the same head: one card', async () => {
- speak('a stream message, so the next deploy is a new card');
+ await speak('a stream message, so the next deploy is a new card');
const commits = [commit('c', 'bridge: head'), commit('d', 'bridge: parent')];
const commits = [commit('c', 'bridge: head'), commit('d', 'bridge: parent')];
const b = await bridge(commits);
const b = await bridge(commits);
const id = b.body.results[0].result.id;
const id = b.body.results[0].result.id;
assert.strictEqual(b.body.results[0].result.announced, 2);
assert.strictEqual(b.body.results[0].result.announced, 2);
- const before = cards().length;
+ const before = (await cards()).length;
const r = await deliver(release(commits));
const r = await deliver(release(commits));
assert.deepStrictEqual([r.status, r.body.duplicate, r.body.outcome], [200, false, 'duplicate:release']);
assert.deepStrictEqual([r.status, r.body.duplicate, r.body.outcome], [200, false, 'duplicate:release']);
assert.strictEqual(r.body.detail.message_id, id);
assert.strictEqual(r.body.detail.message_id, id);
- assert.strictEqual(cards().length, before, 'no second card');
- assert.strictEqual(card(id).deploys, 1, 'the card was not folded again');
- const rel = releaseRow(commits[0].hash);
+ assert.strictEqual((await cards()).length, before, 'no second card');
+ assert.strictEqual((await card(id)).deploys, 1, 'the card was not folded again');
+ const rel = await releaseRow(commits[0].hash);
assert.strictEqual(rel.first_via, 'bridge');
assert.strictEqual(rel.first_via, 'bridge');
assert.ok(rel.bridge_at && rel.event_at, 'both paths are recorded as having delivered it');
assert.ok(rel.bridge_at && rel.event_at, 'both paths are recorded as having delivered it');
});
});
t('the event first, then the bridge for the same head: one card', async () => {
t('the event first, then the bridge for the same head: one card', async () => {
- speak('another stream message');
+ await speak('another stream message');
const commits = [commit('e', 'event: head')];
const commits = [commit('e', 'event: head')];
const r = await deliver(release(commits));
const r = await deliver(release(commits));
assert.strictEqual(r.body.outcome, 'announced');
assert.strictEqual(r.body.outcome, 'announced');
const id = r.body.detail.message_id;
const id = r.body.detail.message_id;
- const before = cards().length;
+ const before = (await cards()).length;
const b = await bridge(commits);
const b = await bridge(commits);
assert.deepStrictEqual(b.body.results[0].result, { announced: 0, id, duplicate: true });
assert.deepStrictEqual(b.body.results[0].result, { announced: 0, id, duplicate: true });
// A retried bridge op (Live's durable queue after a lost response) is the same.
// A retried bridge op (Live's durable queue after a lost response) is the same.
const again = await bridge(commits);
const again = await bridge(commits);
assert.strictEqual(again.body.results[0].result.duplicate, true);
assert.strictEqual(again.body.results[0].result.duplicate, true);
- assert.strictEqual(cards().length, before);
- assert.strictEqual(card(id).deploys, 1);
- const rel = releaseRow(commits[0].hash);
+ assert.strictEqual((await cards()).length, before);
+ assert.strictEqual((await card(id)).deploys, 1);
+ const rel = await releaseRow(commits[0].hash);
assert.deepStrictEqual([rel.first_via, !!rel.event_at, !!rel.bridge_at], ['events', true, true]);
assert.deepStrictEqual([rel.first_via, !!rel.event_at, !!rel.bridge_at], ['events', true, true]);
});
});
t('folding as before: the next deploy folds into the last card; a stream message in between starts a new card', async () => {
t('folding as before: the next deploy folds into the last card; a stream message in between starts a new card', async () => {
- speak('new card next');
+ await speak('new card next');
let r = await deliver(release([commit('1', 'fold: one')]));
let r = await deliver(release([commit('1', 'fold: one')]));
const first = r.body.detail.message_id;
const first = r.body.detail.message_id;
assert.strictEqual(r.body.outcome, 'announced');
assert.strictEqual(r.body.outcome, 'announced');
@@ -275,23 +275,23 @@ t('folding as before: the next deploy folds into the last card; a stream message
@@ -275,23 +275,23 @@ t('folding as before: the next deploy folds into the last card; a stream message
assert.strictEqual(b.body.results[0].result.id, first, 'the bridge folds into the card the event made');
assert.strictEqual(b.body.results[0].result.id, first, 'the bridge folds into the card the event made');
r = await deliver(release([commit('3', 'fold: three')]));
r = await deliver(release([commit('3', 'fold: three')]));
assert.deepStrictEqual([r.body.outcome, r.body.detail.message_id], ['folded', first]);
assert.deepStrictEqual([r.body.outcome, r.body.detail.message_id], ['folded', first]);
- assert.strictEqual(card(first).deploys, 3);
- assert.deepStrictEqual(card(first).commits.map((c) => c.subject), ['fold: three', 'fold: two', 'fold: one']);
+ assert.strictEqual((await card(first)).deploys, 3);
+ assert.deepStrictEqual((await card(first)).commits.map((c) => c.subject), ['fold: three', 'fold: two', 'fold: one']);
r = await deliver(release([commit('2', 'fold: two')]));
r = await deliver(release([commit('2', 'fold: two')]));
assert.strictEqual(r.body.outcome, 'duplicate:release', 'the bridge already announced that head');
assert.strictEqual(r.body.outcome, 'duplicate:release', 'the bridge already announced that head');
- assert.strictEqual(card(first).deploys, 3);
+ assert.strictEqual((await card(first)).deploys, 3);
// Someone speaks in a stream room (the global feed shows it): the next deploy is a new card.
// Someone speaks in a stream room (the global feed shows it): the next deploy is a new card.
- speak('hello brother');
+ await speak('hello brother');
r = await deliver(release([commit('4', 'fold: four')]));
r = await deliver(release([commit('4', 'fold: four')]));
assert.strictEqual(r.body.outcome, 'announced');
assert.strictEqual(r.body.outcome, 'announced');
const next = r.body.detail.message_id;
const next = r.body.detail.message_id;
assert.notStrictEqual(next, first);
assert.notStrictEqual(next, first);
- assert.strictEqual(card(next).deploys, 1);
- assert.strictEqual(card(first).deploys, 3, 'the old card is left as it was');
+ assert.strictEqual((await card(next)).deploys, 1);
+ assert.strictEqual((await card(first)).deploys, 3, 'the old card is left as it was');
});
});
t('what a release event must be: from Live, a release subject, commits, not stale', async () => {
t('what a release event must be: from Live, a release subject, commits, not stale', async () => {
- const before = cards().length;
+ const before = (await cards()).length;
const ev = release([commit('5')]);
const ev = release([commit('5')]);
let r = await deliver({ ...ev, event_id: ids.newId('event'), source: 'media' });
let r = await deliver({ ...ev, event_id: ids.newId('event'), source: 'media' });
assert.strictEqual(r.body.outcome, 'ignored:source');
assert.strictEqual(r.body.outcome, 'ignored:source');
@@ -303,8 +303,8 @@ t('what a release event must be: from Live, a release subject, commits, not stal
@@ -303,8 +303,8 @@ t('what a release event must be: from Live, a release subject, commits, not stal
assert.strictEqual(r.body.outcome, 'ignored:stale', 'an operator replay of an old deploy says nothing');
assert.strictEqual(r.body.outcome, 'ignored:stale', 'an operator replay of an old deploy says nothing');
r = await deliver({ ...ev, event_id: ids.newId('event'), event_type: 'live.stream.started' });
r = await deliver({ ...ev, event_id: ids.newId('event'), event_type: 'live.stream.started' });
assert.strictEqual(r.body.outcome, 'ignored:type');
assert.strictEqual(r.body.outcome, 'ignored:type');
- assert.strictEqual(cards().length, before);
- assert.strictEqual(releaseRow(commit('6').hash), undefined);
+ assert.strictEqual((await cards()).length, before);
+ assert.strictEqual(await releaseRow(commit('6').hash), undefined);
});
});
t('network.module.updated drops a cached chat.preferences copy when newer; an older revision is ignored', async () => {
t('network.module.updated drops a cached chat.preferences copy when newer; an older revision is ignored', async () => {
@@ -325,12 +325,12 @@ t('network.module.updated drops a cached chat.preferences copy when newer; an ol
@@ -325,12 +325,12 @@ t('network.module.updated drops a cached chat.preferences copy when newer; an ol
assert.strictEqual(r.body.outcome, 'unchanged');
assert.strictEqual(r.body.outcome, 'unchanged');
assert.strictEqual(prefs._cached(ANN).revision, 2);
assert.strictEqual(prefs._cached(ANN).revision, 2);
// ...other namespaces and other sources are acknowledged and not recorded...
// ...other namespaces and other sources are acknowledged and not recorded...
- const inbox = h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n;
+ const inbox = (await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n;
r = await deliver(moduleUpdated(9, { namespace: 'tools.usage' }));
r = await deliver(moduleUpdated(9, { namespace: 'tools.usage' }));
assert.strictEqual(r.body.outcome, 'ignored:namespace');
assert.strictEqual(r.body.outcome, 'ignored:namespace');
r = await deliver(moduleUpdated(9, { source: 'live' }));
r = await deliver(moduleUpdated(9, { source: 'live' }));
assert.strictEqual(r.body.outcome, 'ignored:source');
assert.strictEqual(r.body.outcome, 'ignored:source');
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n, inbox);
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n, inbox);
assert.strictEqual(prefs._cached(ANN).revision, 2);
assert.strictEqual(prefs._cached(ANN).revision, 2);
// ...and the newer revision drops it at once: the next read is Network's.
// ...and the newer revision drops it at once: the next read is Network's.
const ev = moduleUpdated(3);
const ev = moduleUpdated(3);
@@ -368,7 +368,7 @@ t('vip.membership.changed from VIP drops the cached badge answers at once; from
@@ -368,7 +368,7 @@ t('vip.membership.changed from VIP drops the cached badge answers at once; from
t('an event delivered with the secret the subscription holds verifies; /ready reports the consumer', async () => {
t('an event delivered with the secret the subscription holds verifies; /ready reports the consumer', async () => {
const sub = stubEvents.subs.find((s) => s.topic_pattern === 'live.release.deployed');
const sub = stubEvents.subs.find((s) => s.topic_pattern === 'live.release.deployed');
- speak('end-to-end next');
+ await speak('end-to-end next');
const r = await deliver(release([commit('7', 'through the subscription')]), { secret: sub.secret, headers: { 'X-OpenVibe-Subscription-Id': sub.id } });
const r = await deliver(release([commit('7', 'through the subscription')]), { secret: sub.secret, headers: { 'X-OpenVibe-Subscription-Id': sub.id } });
assert.deepStrictEqual([r.status, r.body.outcome], [200, 'announced']);
assert.deepStrictEqual([r.status, r.body.outcome], [200, 'announced']);
const ready = await h.http('GET', '/ready');
const ready = await h.http('GET', '/ready');
@@ -380,10 +380,10 @@ t('an event delivered with the secret the subscription holds verifies; /ready re
@@ -380,10 +380,10 @@ t('an event delivered with the secret the subscription holds verifies; /ready re
});
});
t('inbox receipts older than Events’ retention are pruned', async () => {
t('inbox receipts older than Events’ retention are pruned', async () => {
- h.db.run("INSERT INTO chat_event_inbox (consumer, event_id, processed_at) VALUES ('chat', 'evt_OLD', ?)", [Date.now() - 40 * 86400e3]);
- const kept = h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n - 1;
- assert.strictEqual(h.eventsConsumer.prune(), 1);
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox').n, kept);
+ await h.db.run("INSERT INTO chat_event_inbox (consumer, event_id, processed_at) VALUES ('chat', 'evt_OLD', ?)", [Date.now() - 40 * 86400e3]);
+ const kept = (await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n - 1;
+ assert.strictEqual(await h.eventsConsumer.prune(), 1);
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_event_inbox')).n, kept);
});
});
t.run(async () => { if (h) await h.close(); eventsServer.close(); });
t.run(async () => { if (h) await h.close(); eventsServer.close(); });
@@ -285,7 +285,6 @@ async function boot({ env = {} } = {}) {
@@ -285,7 +285,6 @@ async function boot({ env = {} } = {}) {
HOST: '127.0.0.1',
HOST: '127.0.0.1',
BASE_URL: 'https://openvibe.live',
BASE_URL: 'https://openvibe.live',
TRUST_PROXY: '2',
TRUST_PROXY: '2',
- CHAT_DB_PATH: path.join(tmp, 'chat.db'),
CHAT_CACHE_DIR: path.join(tmp, 'cache'),
CHAT_CACHE_DIR: path.join(tmp, 'cache'),
SOUNDS_PATH: path.join(tmp, 'sounds'),
SOUNDS_PATH: path.join(tmp, 'sounds'),
OV_NETWORK_URL: ISS,
OV_NETWORK_URL: ISS,
@@ -319,14 +318,14 @@ async function boot({ env = {} } = {}) {
@@ -319,14 +318,14 @@ async function boot({ env = {} } = {}) {
return id;
return id;
},
},
/** Write the channel's own moderation settings row (the six chat tables are Chat's; C-04). */
/** Write the channel's own moderation settings row (the six chat tables are Chat's; C-04). */
- setChannelSettings(channelId, settings) {
+ async setChannelSettings(channelId, settings) {
const d = h.db.getDb();
const d = h.db.getDb();
- d.prepare('DELETE FROM channel_moderation_settings WHERE channel_id = ?').run(Number(channelId));
+ await d.prepare('DELETE FROM channel_moderation_settings WHERE channel_id = ?').run(Number(channelId));
if (settings) {
if (settings) {
const keys = Object.keys(settings).filter((k) => /^[a-z_]+$/.test(k));
const keys = Object.keys(settings).filter((k) => /^[a-z_]+$/.test(k));
- d.prepare(`INSERT INTO channel_moderation_settings (channel_id, ${keys.join(', ')}) VALUES (?, ${keys.map(() => '?').join(', ')})`).run(Number(channelId), ...keys.map((k) => settings[k]));
+ await d.prepare(`INSERT INTO channel_moderation_settings (channel_id, ${keys.join(', ')}) VALUES (?, ${keys.map(() => '?').join(', ')})`).run(Number(channelId), ...keys.map((k) => settings[k]));
}
}
- h.ctx.invalidateChannel(Number(channelId));
+ await h.ctx.invalidateChannel(Number(channelId));
},
},
addStream(userId, channelId, { title = 'Live now', is_live = 1, managed = null } = {}) {
addStream(userId, channelId, { title = 'Live now', is_live = 1, managed = null } = {}) {
const id = 500 + live.streams.size;
const id = 500 + live.streams.size;
@@ -403,15 +402,19 @@ async function boot({ env = {} } = {}) {
@@ -403,15 +402,19 @@ async function boot({ env = {} } = {}) {
try { h.chatServer.close(); } catch { /* */ }
try { h.chatServer.close(); } catch { /* */ }
try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); } catch { /* */ }
try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); } catch { /* */ }
await new Promise((r) => h.server.close(() => r()));
await new Promise((r) => h.server.close(() => r()));
- try { h.db.close(); } catch { /* */ }
+ try { await h.db.close(); } catch { /* */ }
h.children = [];
h.children = [];
};
};
+ /** The environment a process this test spawns needs to reach the same database (a script, a second Chat). */
+ h.childDbEnv = async () => (globalThis.__ovChatTestChildEnv ? await globalThis.__ovChatTestChildEnv() : {});
h.freePort = () => new Promise((r) => { const s = http.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });
h.freePort = () => new Promise((r) => { const s = http.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });
h.spawnChat = async ({ port, env = {} } = {}) => {
h.spawnChat = async ({ port, env = {} } = {}) => {
const { spawn } = require('child_process');
const { spawn } = require('child_process');
+ // The same database as this process (test/helpers/pg-preload.mjs): the run's schema, or this PGlite over a socket.
+ const dbEnv = await h.childDbEnv();
const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
cwd: path.join(__dirname, '..'),
cwd: path.join(__dirname, '..'),
- env: { ...process.env, PORT: String(port), ...env },
+ env: { ...process.env, ...dbEnv, PORT: String(port), ...env },
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
});
});
child.log = '';
child.log = '';
@@ -443,7 +446,7 @@ async function boot({ env = {} } = {}) {
@@ -443,7 +446,7 @@ async function boot({ env = {} } = {}) {
try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }
try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); h.eventsConsumer.stop(); h.subscriptions.stop(); } catch { /* */ }
await new Promise((r) => h.server.close(() => r()));
await new Promise((r) => h.server.close(() => r()));
network.close(); liveServer.close(); if (liveServer.closeAllConnections) liveServer.closeAllConnections();
network.close(); liveServer.close(); if (liveServer.closeAllConnections) liveServer.closeAllConnections();
- try { h.db.close(); } catch { /* */ }
+ try { await h.db.close(); } catch { /* */ }
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
};
};
h.sleep = (ms) => new Promise((r) => setTimeout(r, ms));
h.sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -461,6 +464,7 @@ function suite(name) {
@@ -461,6 +464,7 @@ function suite(name) {
try { await s.fn(); process.stdout.write(` ✓ ${s.label}\n`); } catch (err) { failed++; process.stdout.write(` ✗ ${s.label}\n${err && err.stack || err}\n`); break; }
try { await s.fn(); process.stdout.write(` ✓ ${s.label}\n`); } catch (err) { failed++; process.stdout.write(` ✗ ${s.label}\n${err && err.stack || err}\n`); break; }
}
}
try { if (cleanup) await cleanup(); } catch { /* */ }
try { if (cleanup) await cleanup(); } catch { /* */ }
+ try { if (globalThis.__ovChatTestDbClose) await globalThis.__ovChatTestDbClose(); } catch { /* */ }
process.stdout.write(`${name}: ${failed ? 'FAILED' : `${steps.length} checks passed`}\n`);
process.stdout.write(`${name}: ${failed ? 'FAILED' : `${steps.length} checks passed`}\n`);
process.exit(failed ? 1 : 0);
process.exit(failed ? 1 : 0);
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+// Loaded with `node --import` into every test process (test/run.js): one migrated database for the process (ADR-035),
+// an in-memory PGlite by default or, with OV_TEST_STORE=pg (npm run test:pg), PostgreSQL through PgBouncer with roles
+// and a schema of its own (openvibe-sdk/testing createTestDb). server/db/database.js takes it on first use, so test
+// files open no database themselves. A Chat the test spawns as its own process (a restart) gets the same database:
+// DATABASE_URL/DATABASE_DIRECT_URL of the run's schema, or, on PGlite, this process's instance served over the
+// PostgreSQL protocol (openChildDatabase below, with a one-connection pool: PGlite is one session).
+import { createRequire } from 'node:module';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const require = createRequire(import.meta.url);
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
+const { createTestDb } = require('openvibe-sdk/testing');
+const { createDb } = require('openvibe-sdk/db');
+
+const migrations = path.join(root, 'migrations');
+const quiet = { log() {}, info() {}, warn: (...a) => console.warn(...a), error: (...a) => console.error(...a) };
+
+if ((process.env.OV_TEST_STORE || 'pglite') === 'pg') {
+ const t = await createTestDb({ migrations, store: 'pg', service: 'chat', max: 4, log: quiet });
+ globalThis.__ovChatTestDb = t.db;
+ globalThis.__ovChatTestChildEnv = async () => ({ DATABASE_URL: t.url, DATABASE_DIRECT_URL: t.directUrl });
+ // The owner role on a direct connection, for a test that changes the schema (the runtime role is DML only).
+ let owner = null;
+ globalThis.__ovChatTestOwnerDb = () => (owner ||= createDb({ url: t.directUrl, service: 'chat-test-owner', max: 1, log: quiet }));
+ globalThis.__ovChatTestDbClose = async () => { if (owner) await owner.close().catch(() => {}); await t.close(); };
+} else {
+ const { PGlite } = require('@electric-sql/pglite');
+ const { PARSERS } = require('openvibe-sdk/db');
+ const parsers = {};
+ for (const [oid, fn] of Object.entries(PARSERS)) parsers[oid] = (v) => fn(v);
+ const instance = new PGlite(undefined, { parsers });
+ const db = createDb({ pglite: instance, service: 'chat-test', log: quiet });
+ await db.migrate({ dir: migrations, log: quiet });
+ let server = null;
+ globalThis.__ovChatTestDb = db;
+ globalThis.__ovChatTestOwnerDb = () => db;
+ globalThis.__ovChatTestDbClose = async () => { if (server) await server.stop().catch(() => {}); await db.close().catch(() => {}); };
+ globalThis.__ovChatTestChildEnv = async () => {
+ if (!server) {
+ const { PGLiteSocketServer } = require('@electric-sql/pglite-socket');
+ // Several connections may be open at once (the child's migrator closing as its pool opens); the pool has one.
+ server = new PGLiteSocketServer({ db: instance, port: 0, host: '127.0.0.1', maxConnections: 4 });
+ await server.start();
+ }
+ const url = `postgres://postgres:[email protected]:${server.server.address().port}/postgres`;
+ return { DATABASE_URL: url, DATABASE_DIRECT_URL: url, DATABASE_POOL_MAX: '1' };
+ };
+}
@@ -8,7 +8,7 @@ const assert = require('assert');
@@ -8,7 +8,7 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { openSqlite } = require('../scripts/lib/sqlite');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const { suite } = require('./helpers');
const { suite } = require('./helpers');
const { main } = require('../scripts/import-sqlite-to-pg');
const { main } = require('../scripts/import-sqlite-to-pg');
@@ -18,8 +18,8 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));
@@ -18,8 +18,8 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));
const fixture = path.join(tmp, 'chat.db');
const fixture = path.join(tmp, 'chat.db');
const pgliteDir = path.join(tmp, 'pglite');
const pgliteDir = path.join(tmp, 'pglite');
-function buildFixture() {
- const d = new Database(fixture);
+async function buildFixture() {
+ const d = openSqlite(fixture, { readonly: false, create: true });
d.exec(`
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 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 relay_users (platform TEXT NOT NULL, username TEXT NOT NULL, display_name TEXT, message_count INTEGER DEFAULT 0, PRIMARY KEY (platform, username));
@@ -29,18 +29,18 @@ function buildFixture() {
@@ -29,18 +29,18 @@ function buildFixture() {
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);
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 (?, ?, ?, ?)');
const msg = d.prepare('INSERT INTO chat_messages (user_id, username, message, timestamp) VALUES (?, ?, ?, ?)');
- for (let i = 1; i <= 120; i++) msg.run(1000 + i, `user${i}`, `message ${i}`, '2026-09-29 10:00:00');
- d.prepare('INSERT INTO relay_users (platform, username, display_name, message_count) VALUES (?, ?, ?, ?)').run('twitch', 'nightbot', 'Nightbot', 42);
- d.prepare('INSERT INTO rooms (slug, name, owner_id, message_count) VALUES (?, ?, ?, ?)').run('general', 'General', 1001, 5);
- d.prepare('INSERT INTO room_messages (room_id, user_id, message) VALUES (?, ?, ?)').run(1, 1001, 'hi room');
- d.prepare('INSERT INTO chat_meta (key, value) VALUES (?, ?)').run('deploy_head', 'abc123');
- d.prepare('INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (?, ?, ?, ?)').run(1001, 'kek', '/e/kek.png', 1001);
+ 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();
d.close();
}
}
let report, db;
let report, db;
t('import the fixture', async () => {
t('import the fixture', async () => {
- buildFixture();
+ await buildFixture();
await main(['--sqlite', fixture, '--pglite', pgliteDir]);
await main(['--sqlite', fixture, '--pglite', pgliteDir]);
assert.strictEqual(process.exitCode, undefined, 'the import reported ok');
assert.strictEqual(process.exitCode, undefined, 'the import reported ok');
db = createDb({ pglite: pgliteDir, service: 'chat-import-test' });
db = createDb({ pglite: pgliteDir, service: 'chat-import-test' });
@@ -11,88 +11,91 @@ const assert = require('assert');
@@ -11,88 +11,91 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const { spawnSync } = require('child_process');
-const Database = require('better-sqlite3');
+const { openSqlite } = require('../scripts/lib/sqlite');
+const db = require('../server/db/database');
const { suite } = require('./helpers');
const { suite } = require('./helpers');
const t = suite('import');
const t = suite('import');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));
const liveDb = path.join(tmp, 'live-snapshot.db');
const liveDb = path.join(tmp, 'live-snapshot.db');
-const chatDb = path.join(tmp, 'chat.db');
const SUBJ = 'usr_01J9SUBJECTSUBJECTSUBJECTS';
const SUBJ = 'usr_01J9SUBJECTSUBJECTSUBJECTS';
-function importer(args) {
- const r = spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'import-from-live.js'), '--live-db', liveDb, '--chat-db', chatDb, ...args], {
- cwd: path.join(__dirname, '..'), encoding: 'utf8', env: { ...process.env, NODE_ENV: 'test', OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9' },
- });
+// 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;
let report = null;
try { report = JSON.parse(r.stdout); } catch { /* */ }
try { report = JSON.parse(r.stdout); } catch { /* */ }
- return { code: r.status, report, stderr: r.stderr, stdout: r.stdout };
+ 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;
let live;
-t('build a Live snapshot with every chat table', () => {
- live = new Database(liveDb);
+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.pragma('foreign_keys = OFF');
live.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'live-chat-schema.sql'), 'utf8'));
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;');
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 (?, ?, '!', ?, ?)");
const u = live.prepare("INSERT INTO users (id, username, password_hash, display_name, role) VALUES (?, ?, '!', ?, ?)");
- u.run(1, 'streamer', 'Streamer', 'streamer'); u.run(2, 'alice', 'Alice', 'user'); u.run(3, 'bob', 'Bob', 'user');
- live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '77', ?)").run(SUBJ);
- live.prepare("INSERT INTO channels (id, user_id, title) VALUES (5, 1, 'TV')").run();
- live.prepare("INSERT INTO managed_streams (id, user_id, channel_id, slug, title, stream_key) VALUES (9, 1, 5, 'main', 'Main', 'k')").run();
- live.prepare("INSERT INTO streams (id, user_id, channel_id, managed_stream_id, title, is_live) VALUES (40, 1, 5, 9, 'Show', 1)").run();
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
- msg.run(101, 40, 1, 2, null, 'Alice', 'first', 'chat', null, 0, 0, null, '2026-09-01 10:00:00');
- msg.run(102, 40, 1, 3, null, 'Bob', 'reply', 'chat', null, 0, 0, 101, '2026-09-01 10:00:05');
- msg.run(103, null, null, null, 'anon4', 'anon4', 'global hi', 'chat', null, 1, 0, null, '2026-09-01 10:01:00');
- 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');
- msg.run(105, 40, 1, 2, null, 'Alice', 'deleted one', 'chat', null, 0, 1, null, '2026-09-01 10:03:00');
- live.prepare("INSERT INTO dm_conversations (id, created_by, created_at) VALUES (7, 2, '2026-09-02 00:00:00')").run();
- live.prepare('INSERT INTO dm_participants (conversation_id, user_id) VALUES (7, 2), (7, 3)').run();
- 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();
- live.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id) VALUES (3, 1)').run();
- live.prepare("INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed) VALUES ('user:alice', 'en+f3', 60, 170)").run();
- 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();
- live.prepare("INSERT INTO relay_users (platform, username, display_name, message_count) VALUES ('twitch', 'zed', 'Zed', 3)").run();
- live.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username) VALUES (5, 'twitch', 'spammer')").run();
- 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();
- live.prepare("INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES ('user:2', 1)").run();
- live.prepare("INSERT INTO moderation_actions (scope_type, actor_user_id, target_user_id, action_type, details) VALUES ('channel', 2, 3, 'channel_ban', '{}')").run();
- live.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (5, 2, 1)').run();
- live.prepare('INSERT INTO channel_moderation_settings (channel_id, slow_mode_seconds) VALUES (5, 3)').run();
- live.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (1, 'pog', '/e/pog.png', 1)").run();
- live.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (2, 'og')").run();
- live.prepare("INSERT INTO chat_ai_summaries (scope, subject_id, window, overview) VALUES ('user', 2, '7d', 'nice')").run();
- live.prepare("INSERT INTO chat_timeline_events (scope, ts, label) VALUES ('global', '2026-09-01 10:00:00', 'hype')").run();
- 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();
+ 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");
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)', () => {
+t('dry run reports and writes nothing (the default; --dry-run says it too)', async () => {
for (const args of [[], ['--dry-run']]) {
for (const args of [[], ['--dry-run']]) {
- const r = importer(args);
+ const r = await importer(args);
assert.strictEqual(r.code, 0, r.stderr);
assert.strictEqual(r.code, 0, r.stderr);
assert.strictEqual(r.report.dry_run, true);
assert.strictEqual(r.report.dry_run, true);
assert.strictEqual(r.report.backup, null);
assert.strictEqual(r.report.backup, null);
}
}
- const r = importer([]);
+ const r = await importer([]);
assert.strictEqual(r.report.tables.chat_messages.inserted, 5);
assert.strictEqual(r.report.tables.chat_messages.inserted, 5);
assert.strictEqual(r.report.tables.chat_messages_new.held, 1);
assert.strictEqual(r.report.tables.chat_messages_new.held, 1);
- const c = new Database(chatDb, { readonly: true });
- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, 0);
- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM import_hold').get().n, 0);
- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM import_runs').get().n, 0);
- c.close();
+ 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', () => {
- const r = importer(['--apply', '--headroom', '1000']);
+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.strictEqual(r.code, 0, r.stderr);
assert.ok(r.report.backup && fs.existsSync(r.report.backup), 'backed up first');
assert.ok(r.report.backup && fs.existsSync(r.report.backup), 'backed up first');
- const b = new Database(r.report.backup, { readonly: true });
- assert.strictEqual(b.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, 0, 'the backup is the database before the run');
- b.close();
+ const 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 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 };
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)) {
for (const [tbl, n] of Object.entries(expect)) {
@@ -102,30 +105,29 @@ t('import copies every table with ids kept, subjects filled, headroom set', () =
@@ -102,30 +105,29 @@ t('import copies every table with ids kept, subjects filled, headroom set', () =
assert.strictEqual(T.media_requests.not_moved.startsWith('stays in Live'), true);
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_new.held, 1);
assert.strictEqual(T.chat_messages.next_id, 105 + 1000 + 1);
assert.strictEqual(T.chat_messages.next_id, 105 + 1000 + 1);
- const c = new Database(chatDb, { readonly: true });
- const m = c.prepare('SELECT * FROM chat_messages WHERE id = 102').get();
+ 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.reply_to_id, 101);
assert.strictEqual(m.user_id, 3);
assert.strictEqual(m.user_id, 3);
- assert.strictEqual(c.prepare('SELECT subject_id FROM chat_messages WHERE id = 101').get().subject_id, SUBJ);
- assert.strictEqual(c.prepare('SELECT is_deleted FROM chat_messages WHERE id = 105').get().is_deleted, 1, 'deleted rows are kept, still deleted');
- assert.strictEqual(c.prepare('SELECT metadata FROM chat_messages WHERE id = 104').get().metadata, '{"kind":"donation","amount":5}');
- assert.strictEqual(c.prepare('SELECT sender_subject_id FROM dm_messages WHERE id = 70').get().sender_subject_id, SUBJ);
- assert.strictEqual(c.prepare('SELECT actor_subject_id FROM moderation_actions').get().actor_subject_id, SUBJ);
- assert.strictEqual(c.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'chat_messages'").get().seq, 1105);
- const hold = c.prepare('SELECT * FROM import_hold').all();
+ 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.length, 1);
assert.strictEqual(hold[0].source_table, 'chat_messages_new');
assert.strictEqual(hold[0].source_table, 'chat_messages_new');
assert.match(hold[0].reason, /transient migration table/);
assert.match(hold[0].reason, /transient migration table/);
- assert.strictEqual(c.prepare('SELECT username FROM ctx_users WHERE id = 2').get().username, 'alice', 'projections seeded');
- assert.strictEqual(c.prepare('SELECT subject_id FROM ctx_users WHERE id = 2').get().subject_id, SUBJ);
- assert.strictEqual(c.prepare('SELECT slug FROM ctx_managed_streams WHERE id = 9').get().slug, 'main');
- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM live_mirror_outbox').get().n, 0, 'imported rows are never mirrored back to Live');
- assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM events_outbox').get().n, 0, 'imported history is not re-announced');
- c.close();
+ 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', () => {
- const r = importer(['--apply', '--no-backup', '--headroom', '1000']);
+t('re-running is idempotent', async () => {
+ const r = await importer(['--apply', '--no-backup', '--headroom', '1000']);
assert.strictEqual(r.report.backup, null);
assert.strictEqual(r.report.backup, null);
assert.strictEqual(r.code, 0, r.stderr);
assert.strictEqual(r.code, 0, r.stderr);
for (const [tbl, v] of Object.entries(r.report.tables)) {
for (const [tbl, v] of Object.entries(r.report.tables)) {
@@ -135,39 +137,36 @@ t('re-running is idempotent', () => {
@@ -135,39 +137,36 @@ t('re-running is idempotent', () => {
assert.strictEqual(r.report.held_total, 1, 'holds are not duplicated');
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', () => {
- const c = new Database(chatDb);
- c.prepare('UPDATE chat_messages SET is_deleted = 1 WHERE id = 101').run(); // a moderator deleted it in Chat
- c.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (106, 3, 'Bob', 'written in chat')").run();
- c.close();
- live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (106, 2, 'Alice', 'a different 106', '2026-09-03 00:00:00')").run();
- live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (107, 2, 'Alice', 'written in Live after the first pass', '2026-09-03 00:00:01')").run();
- const r = importer(['--apply', '--no-backup', '--headroom', '1000']);
+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);
assert.strictEqual(r.code, 0, r.stderr);
const T = r.report.tables;
const T = r.report.tables;
assert.strictEqual(T.chat_messages.chat_kept, 1);
assert.strictEqual(T.chat_messages.chat_kept, 1);
assert.strictEqual(T.chat_messages.held, 1);
assert.strictEqual(T.chat_messages.held, 1);
assert.strictEqual(T.chat_messages.inserted, 1);
assert.strictEqual(T.chat_messages.inserted, 1);
- const d = new Database(chatDb, { readonly: true });
- assert.strictEqual(d.prepare('SELECT is_deleted FROM chat_messages WHERE id = 101').get().is_deleted, 1);
- assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 106').get().message, 'written in chat');
- assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 107').get().message, 'written in Live after the first pass');
- const held = d.prepare("SELECT * FROM import_hold WHERE source_table = 'chat_messages'").get();
+ 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.match(held.reason, /different row/);
assert.strictEqual(JSON.parse(held.row_json).message, 'a different 106');
assert.strictEqual(JSON.parse(held.row_json).message, 'a different 106');
- assert.strictEqual(d.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'chat_messages'").get().seq, 1107, 'headroom follows Live’s new max, never lowered');
- d.close();
+ assert.strictEqual(await lastId('chat_messages'), 1107, 'headroom follows Live’s new max, never lowered');
});
});
-t('schema drift is refused before anything is written', () => {
+t('schema drift is refused before anything is written', async () => {
live.exec('ALTER TABLE chat_messages ADD COLUMN surprise TEXT');
live.exec('ALTER TABLE chat_messages ADD COLUMN surprise TEXT');
- live.prepare("INSERT INTO chat_messages (id, username, message, surprise) VALUES (108, 'x', 'y', 'z')").run();
- const r = importer([]);
+ 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.strictEqual(r.code, 1);
assert.match(r.stderr, /chat_messages: surprise/);
assert.match(r.stderr, /chat_messages: surprise/);
- const d = new Database(chatDb, { readonly: true });
- assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_messages WHERE id = 108').get().n, 0);
- d.close();
+ 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 }); });
t.run(() => { try { live.close(); } catch { /* */ } fs.rmSync(tmp, { recursive: true, force: true }); });
@@ -18,8 +18,8 @@ t('boot', async () => {
@@ -18,8 +18,8 @@ t('boot', async () => {
mod = h.addUser('moddy');
mod = h.addUser('moddy');
channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { max_message_length: 60 } });
channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { max_message_length: 60 } });
// The settings and moderator rows are Chat's own (C-04 done); seed them here.
// The settings and moderator rows are Chat's own (C-04 done); seed them here.
- h.setChannelSettings(channelId, { max_message_length: 60 });
- h.db.addChannelModerator(channelId, mod.id, streamer.id);
+ await h.setChannelSettings(channelId, { max_message_length: 60 });
+ await h.db.addChannelModerator(channelId, mod.id, streamer.id);
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
h.live.follows.set(alice.id, [streamer.id]);
h.live.follows.set(alice.id, [streamer.id]);
await h.ctx.sync();
await h.ctx.sync();
@@ -66,14 +66,14 @@ t('Live down: chat keeps flowing from caches; new tokens fall back to anonymous'
@@ -66,14 +66,14 @@ t('Live down: chat keeps flowing from caches; new tokens fall back to anonymous'
});
});
t('invalidation never leaves a gap: a moderator stays a moderator while the policy reloads', async () => {
t('invalidation never leaves a gap: a moderator stays a moderator while the policy reloads', async () => {
- h.ctx.invalidateChannel(channelId);
- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);
+ await h.ctx.invalidateChannel(channelId);
+ assert.strictEqual(await h.ctx.isChannelModerator(mod.id, channelId), true);
await h.sleep(100);
await h.sleep(100);
- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);
+ assert.strictEqual(await h.ctx.isChannelModerator(mod.id, channelId), true);
// A moderator removed from Chat's own row loses the power.
// A moderator removed from Chat's own row loses the power.
- h.db.removeChannelModerator(channelId, mod.id);
+ await h.db.removeChannelModerator(channelId, mod.id);
await h.sleep(150);
await h.sleep(150);
- assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), false);
+ assert.strictEqual(await h.ctx.isChannelModerator(mod.id, channelId), false);
});
});
t('bans: user/stream/site scope, CIDR, and expires_at read as an instant (ISO or SQLite format)', async () => {
t('bans: user/stream/site scope, CIDR, and expires_at read as an instant (ISO or SQLite format)', async () => {
@@ -118,7 +118,7 @@ t('a ban written through Live is in Chat’s cache when the effect answers, even
@@ -118,7 +118,7 @@ t('a ban written through Live is in Chat’s cache when the effect answers, even
});
});
t('followers-only uses the viewer’s warm follow list', async () => {
t('followers-only uses the viewer’s warm follow list', async () => {
- h.setChannelSettings(channelId, { followers_only: 1 });
+ await h.setChannelSettings(channelId, { followers_only: 1 });
await h.sleep(150);
await h.sleep(150);
const stranger = h.addUser('stranger');
const stranger = h.addUser('stranger');
await h.ctx.sync();
await h.ctx.sync();
@@ -45,7 +45,7 @@ t('boot, and some of everything while Live is up', async () => {
@@ -45,7 +45,7 @@ t('boot, and some of everything while Live is up', async () => {
alice = h.addUser('alice', { subject: ids.newId('user') });
alice = h.addUser('alice', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
carol = h.addUser('carol', { subject: ids.newId('user') });
carol = h.addUser('carol', { subject: ids.newId('user') });
- for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
+ for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
[aliceJwt, bobJwt, carolJwt] = [alice, bob, carol].map(jwt);
[aliceJwt, bobJwt, carolJwt] = [alice, bob, carol].map(jwt);
let r = await req('POST', '/api/dm/conversations', { token: aliceJwt, json: { user_ids: [bob.id] } });
let r = await req('POST', '/api/dm/conversations', { token: aliceJwt, json: { user_ids: [bob.id] } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
@@ -25,13 +25,13 @@ const SECRET = 'my number is 555-0199';
@@ -25,13 +25,13 @@ const SECRET = 'my number is 555-0199';
let h, events, eventsDb, eventsPort, admin, alice, streamer, streamId;
let h, events, eventsDb, eventsPort, admin, alice, streamer, streamId;
/** Outbox chat.message.deleted envelopes after `afterSeq`. */
/** Outbox chat.message.deleted envelopes after `afterSeq`. */
-function deletions(afterSeq = 0) {
- return h.db.all("SELECT seq, event FROM events_outbox WHERE event_type = 'chat.message.deleted' AND seq > ? ORDER BY seq", [afterSeq])
+async function deletions(afterSeq = 0) {
+ return (await h.db.all("SELECT seq, event FROM events_outbox WHERE event_type = 'chat.message.deleted' AND seq > ? ORDER BY seq", [afterSeq]))
.map((r) => JSON.parse(r.event));
.map((r) => JSON.parse(r.event));
}
}
-const outboxSeq = () => h.db.get('SELECT COALESCE(MAX(seq), 0) AS s FROM events_outbox').s;
-function say(message, over = {}) {
- return Number(h.db.saveChatMessage({ stream_id: null, user_id: null, anon_id: 'anon7', username: 'anon7', message, message_type: 'chat', is_global: 1, ...over }).lastInsertRowid);
+const outboxSeq = async () => (await h.db.get('SELECT COALESCE(MAX(seq), 0) AS s FROM events_outbox')).s;
+async function say(message, over = {}) {
+ return Number((await h.db.saveChatMessage({ stream_id: null, user_id: null, anon_id: 'anon7', username: 'anon7', message, message_type: 'chat', is_global: 1, ...over })).lastInsertRowid);
}
}
function assertDeletion(env, ids) {
function assertDeletion(env, ids) {
const v = validate('events.event-envelope@1', env);
const v = validate('events.event-envelope@1', env);
@@ -79,100 +79,100 @@ t('boot (a real OpenVibe.Events on a reserved port when one is checked out next
@@ -79,100 +79,100 @@ t('boot (a real OpenVibe.Events on a reserved port when one is checked out next
});
});
t('one message deleted (a moderator, or Live’s bridge): one deletion event, ids only', async () => {
t('one message deleted (a moderator, or Live’s bridge): one deletion event, ids only', async () => {
- const id = say(SECRET);
- const from = outboxSeq();
- h.db.deleteChatMessage(id, admin.id);
- const [d] = deletions(from);
+ const id = await say(SECRET);
+ const from = await outboxSeq();
+ await h.db.deleteChatMessage(id, admin.id);
+ const [d] = await deletions(from);
assertDeletion(d, [id]);
assertDeletion(d, [id]);
assert.ok(!JSON.stringify(d).includes('555-0199') && !JSON.stringify(d).includes('anon7'));
assert.ok(!JSON.stringify(d).includes('555-0199') && !JSON.stringify(d).includes('anon7'));
// Through the bridge, the way Live's /api/mod forwards it.
// Through the bridge, the way Live's /api/mod forwards it.
- const id2 = say('via the bridge');
+ const id2 = await say('via the bridge');
const r = await h.http('POST', '/internal/live/calls', { token: h.serviceToken(['chat.live_bridge.write']), body: { boot: 'b1', ops: [{ seq: 1, op: 'db', args: ['deleteChatMessage', id2, admin.id] }] } });
const r = await h.http('POST', '/internal/live/calls', { token: h.serviceToken(['chat.live_bridge.write']), body: { boot: 'b1', ops: [{ seq: 1, op: 'db', args: ['deleteChatMessage', id2, admin.id] }] } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assertDeletion(deletions(from).at(-1), [id2]);
- const before = outboxSeq();
- h.db.deleteChatMessage(999999, admin.id);
- assert.strictEqual(outboxSeq(), before, 'nothing announced for a message that does not exist');
+ assertDeletion((await deletions(from)).at(-1), [id2]);
+ const before = await outboxSeq();
+ await h.db.deleteChatMessage(999999, admin.id);
+ assert.strictEqual(await outboxSeq(), before, 'nothing announced for a message that does not exist');
});
});
t('a user’s, an anon’s and a relay user’s history (self-delete, /api/mod purge)', async () => {
t('a user’s, an anon’s and a relay user’s history (self-delete, /api/mod purge)', async () => {
- const mine = [say('a1', { user_id: alice.id, anon_id: null, username: 'alice' }), say('a2', { user_id: alice.id, anon_id: null, username: 'alice' })];
- const anon = [say('n1', { anon_id: 'anon99', username: 'anon99' })];
- const relay = [say('r1', { username: '[Twitch] zed', anon_id: null })];
- let from = outboxSeq();
- assert.deepStrictEqual(h.db.deleteUserChatMessages(alice.id, { deletedBy: alice.id }), mine, 'return value unchanged');
- assertDeletion(deletions(from)[0], mine);
- from = outboxSeq();
- assert.deepStrictEqual(h.db.deleteAnonChatMessages('anon99', { deletedBy: null }), anon);
- assertDeletion(deletions(from)[0], anon);
- from = outboxSeq();
- assert.deepStrictEqual(h.db.deleteRelayUserMessages('[Twitch] zed', { deletedBy: admin.id }), relay);
- assertDeletion(deletions(from)[0], relay);
- from = outboxSeq();
- assert.deepStrictEqual(h.db.deleteUserChatMessages(alice.id, { deletedBy: alice.id }), [], 'nothing left');
- assert.deepStrictEqual(deletions(from), [], 'no empty deletion events');
+ const mine = [await say('a1', { user_id: alice.id, anon_id: null, username: 'alice' }), await say('a2', { user_id: alice.id, anon_id: null, username: 'alice' })];
+ const anon = [await say('n1', { anon_id: 'anon99', username: 'anon99' })];
+ const relay = [await say('r1', { username: '[Twitch] zed', anon_id: null })];
+ let from = await outboxSeq();
+ assert.deepStrictEqual(await h.db.deleteUserChatMessages(alice.id, { deletedBy: alice.id }), mine, 'return value unchanged');
+ assertDeletion((await deletions(from))[0], mine);
+ from = await outboxSeq();
+ assert.deepStrictEqual(await h.db.deleteAnonChatMessages('anon99', { deletedBy: null }), anon);
+ assertDeletion((await deletions(from))[0], anon);
+ from = await outboxSeq();
+ assert.deepStrictEqual(await h.db.deleteRelayUserMessages('[Twitch] zed', { deletedBy: admin.id }), relay);
+ assertDeletion((await deletions(from))[0], relay);
+ from = await outboxSeq();
+ assert.deepStrictEqual(await h.db.deleteUserChatMessages(alice.id, { deletedBy: alice.id }), [], 'nothing left');
+ assert.deepStrictEqual(await deletions(from), [], 'no empty deletion events');
});
});
t('self-delete over the socket announces what it deleted', async () => {
t('self-delete over the socket announces what it deleted', async () => {
const ws = await h.ws({ ip: '198.51.100.77', token: alice.token });
const ws = await h.ws({ ip: '198.51.100.77', token: alice.token });
ws.sendJson({ type: 'join', token: alice.token });
ws.sendJson({ type: 'join', token: alice.token });
await ws.next((m) => m.type === 'auth');
await ws.next((m) => m.type === 'auth');
- const id = say('from alice', { user_id: alice.id, anon_id: null, username: 'alice' });
- const from = outboxSeq();
+ const id = await say('from alice', { user_id: alice.id, anon_id: null, username: 'alice' });
+ const from = await outboxSeq();
ws.sendJson({ type: 'self-delete-history' });
ws.sendJson({ type: 'self-delete-history' });
const res = await ws.next((m) => m.type === 'self-delete-result');
const res = await ws.next((m) => m.type === 'self-delete-result');
assert.strictEqual(res.count, 1);
assert.strictEqual(res.count, 1);
- assertDeletion(deletions(from)[0], [id]);
+ assertDeletion((await deletions(from))[0], [id]);
ws.close();
ws.close();
});
});
t('a time-range purge (REST) and the auto-delete sweep', async () => {
t('a time-range purge (REST) and the auto-delete sweep', async () => {
- const inRoom = [say('s1', { stream_id: streamId, is_global: 0 }), say('s2', { stream_id: streamId, is_global: 0 })];
- const global = say('g1');
- let from = outboxSeq();
+ const inRoom = [await say('s1', { stream_id: streamId, is_global: 0 }), await say('s2', { stream_id: streamId, is_global: 0 })];
+ const global = await say('g1');
+ let from = await outboxSeq();
const r = await h.http('DELETE', '/api/chat/admin/purge', { token: admin.token, body: { streamId, from: h.sqliteNow(-60000), to: h.sqliteNow(60000) } });
const r = await h.http('DELETE', '/api/chat/admin/purge', { token: admin.token, body: { streamId, from: h.sqliteNow(-60000), to: h.sqliteNow(60000) } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.body.deleted, 2);
assert.strictEqual(r.body.deleted, 2);
- assertDeletion(deletions(from)[0], inRoom);
- assert.ok(!h.db.getChatMessageById(global).is_deleted, 'the other room is untouched');
+ assertDeletion((await deletions(from))[0], inRoom);
+ assert.ok(!(await h.db.getChatMessageById(global)).is_deleted, 'the other room is untouched');
// The dashboard sends ISO instants ('…T…Z'); rows keep 'YYYY-MM-DD HH:MM:SS'. Read as TEXT,
// The dashboard sends ISO instants ('…T…Z'); rows keep 'YYYY-MM-DD HH:MM:SS'. Read as TEXT,
// a same-day range matched nothing. Preview, purge and the log filter read both forms.
// a same-day range matched nothing. Preview, purge and the log filter read both forms.
- const older = say('s-older', { stream_id: streamId, is_global: 0 });
- h.db.run('UPDATE chat_messages SET timestamp = ? WHERE id = ?', [h.sqliteNow(-10 * 60e3), older]);
- const recent = [say('s3', { stream_id: streamId, is_global: 0 }), say('s4', { stream_id: streamId, is_global: 0 })];
+ const older = await say('s-older', { stream_id: streamId, is_global: 0 });
+ await h.db.run('UPDATE chat_messages SET timestamp = ? WHERE id = ?', [h.sqliteNow(-10 * 60e3), older]);
+ const recent = [await say('s3', { stream_id: streamId, is_global: 0 }), await say('s4', { stream_id: streamId, is_global: 0 })];
const range = { streamId, from: new Date(Date.now() - 60e3).toISOString(), to: new Date(Date.now() + 60e3).toISOString() };
const range = { streamId, from: new Date(Date.now() - 60e3).toISOString(), to: new Date(Date.now() + 60e3).toISOString() };
assert.strictEqual((await h.http('POST', '/api/chat/admin/purge/preview', { token: admin.token, body: range })).body.count, 2);
assert.strictEqual((await h.http('POST', '/api/chat/admin/purge/preview', { token: admin.token, body: range })).body.count, 2);
const logs = await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}&from=${encodeURIComponent(range.from)}&to=${encodeURIComponent(range.to)}`, { token: admin.token });
const logs = await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}&from=${encodeURIComponent(range.from)}&to=${encodeURIComponent(range.to)}`, { token: admin.token });
assert.deepStrictEqual(logs.body.rows.map((m) => m.id).sort(), [...recent].sort(), 'the log filter reads ISO too');
assert.deepStrictEqual(logs.body.rows.map((m) => m.id).sort(), [...recent].sort(), 'the log filter reads ISO too');
- from = outboxSeq();
+ from = await outboxSeq();
const iso = await h.http('DELETE', '/api/chat/admin/purge', { token: admin.token, body: range });
const iso = await h.http('DELETE', '/api/chat/admin/purge', { token: admin.token, body: range });
assert.strictEqual(iso.body.deleted, 2, iso.text);
assert.strictEqual(iso.body.deleted, 2, iso.text);
- assertDeletion(deletions(from)[0], recent);
- assert.ok(!h.db.getChatMessageById(older).is_deleted, 'ten minutes ago is outside the range');
- const expiring = say('ephemeral', { auto_delete_at: h.sqliteNow(-1000) });
- from = outboxSeq();
- const swept = h.db.deleteExpiredChatMessages(500);
+ assertDeletion((await deletions(from))[0], recent);
+ assert.ok(!(await h.db.getChatMessageById(older)).is_deleted, 'ten minutes ago is outside the range');
+ const expiring = await say('ephemeral', { auto_delete_at: h.sqliteNow(-1000) });
+ from = await outboxSeq();
+ const swept = await h.db.deleteExpiredChatMessages(500);
assert.deepStrictEqual(swept.map((x) => x.id), [expiring]);
assert.deepStrictEqual(swept.map((x) => x.id), [expiring]);
- assertDeletion(deletions(from)[0], [expiring]);
+ assertDeletion((await deletions(from))[0], [expiring]);
});
});
t('more than 500 ids: one event per 500, in order', async () => {
t('more than 500 ids: one event per 500, in order', async () => {
const ids = [];
const ids = [];
- h.db.transaction(() => { for (let i = 0; i < 1203; i++) ids.push(say(`bulk ${i}`, { anon_id: 'anon500', username: 'anon500' })); });
- const from = outboxSeq();
- h.db.deleteAnonChatMessages('anon500', { deletedBy: admin.id });
- const d = deletions(from);
+ await h.db.tx(async () => { for (let i = 0; i < 1203; i++) ids.push(await say(`bulk ${i}`, { anon_id: 'anon500', username: 'anon500' })); });
+ const from = await outboxSeq();
+ await h.db.deleteAnonChatMessages('anon500', { deletedBy: admin.id });
+ const d = await deletions(from);
assert.deepStrictEqual(d.map((e) => e.payload.message_ids.length), [500, 500, 203]);
assert.deepStrictEqual(d.map((e) => e.payload.message_ids.length), [500, 500, 203]);
assert.deepStrictEqual(d.flatMap((e) => e.payload.message_ids), ids);
assert.deepStrictEqual(d.flatMap((e) => e.payload.message_ids), ids);
for (const e of d) assert.ok(Buffer.byteLength(JSON.stringify(e.payload)) < 64 * 1024, 'fits Events’ payload limit');
for (const e of d) assert.ok(Buffer.byteLength(JSON.stringify(e.payload)) < 64 * 1024, 'fits Events’ payload limit');
});
});
t('a failed delete leaves no event (same transaction)', async () => {
t('a failed delete leaves no event (same transaction)', async () => {
- const id = say('stays');
- const from = outboxSeq();
- assert.throws(() => h.db.transaction(() => { h.db.deleteChatMessage(id, admin.id); throw new Error('boom'); }), /boom/);
- assert.deepStrictEqual(deletions(from), []);
- assert.ok(!h.db.getChatMessageById(id).is_deleted);
+ const id = await say('stays');
+ const from = await outboxSeq();
+ await assert.rejects(async () => await h.db.tx(async () => { await h.db.deleteChatMessage(id, admin.id); throw new Error('boom'); }), /boom/);
+ assert.deepStrictEqual(await deletions(from), []);
+ assert.ok(!(await h.db.getChatMessageById(id)).is_deleted);
});
});
// ── End to end with OpenVibe.Events ──────────────────────────────
// ── End to end with OpenVibe.Events ──────────────────────────────
@@ -208,8 +208,8 @@ const userJwt = (subjectId) => serviceAuth.signServiceToken({
@@ -208,8 +208,8 @@ const userJwt = (subjectId) => serviceAuth.signServiceToken({
let firstSeq, msgId;
let firstSeq, msgId;
t('e2e: the relay publishes the message, then its deletion; replay serves only the tombstone', async () => {
t('e2e: the relay publishes the message, then its deletion; replay serves only the tombstone', async () => {
if (!haveEvents) return;
if (!haveEvents) return;
- h.db.run('UPDATE events_outbox SET sent_at = ? WHERE sent_at IS NULL', [new Date().toISOString()]); // earlier tests' rows
- msgId = say(SECRET, { user_id: alice.id, anon_id: null, username: 'alice' });
+ await h.db.run('UPDATE events_outbox SET sent_at = ? WHERE sent_at IS NULL', [new Date().toISOString()]); // earlier tests' rows
+ msgId = await say(SECRET, { user_id: alice.id, anon_id: null, username: 'alice' });
assert.strictEqual((await h.eventsRelay.flush()).sent, 1);
assert.strictEqual((await h.eventsRelay.flush()).sent, 1);
firstSeq = await events.store.lastSeq();
firstSeq = await events.store.lastSeq();
const before = await sse(`/realtime/stream?topics=chat.message.*&last_event_id=${firstSeq - 1}`);
const before = await sse(`/realtime/stream?topics=chat.message.*&last_event_id=${firstSeq - 1}`);
@@ -217,7 +217,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
@@ -217,7 +217,7 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
assert.ok(before.body().includes('555-0199'), 'before the deletion the text is there (the leak)');
assert.ok(before.body().includes('555-0199'), 'before the deletion the text is there (the leak)');
before.close();
before.close();
- h.db.deleteChatMessage(msgId, admin.id);
+ await h.db.deleteChatMessage(msgId, admin.id);
assert.strictEqual((await h.eventsRelay.flush()).sent, 1);
assert.strictEqual((await h.eventsRelay.flush()).sent, 1);
const views = [
const views = [
['anonymous', {}],
['anonymous', {}],
@@ -246,10 +246,10 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
@@ -246,10 +246,10 @@ t('e2e: the relay publishes the message, then its deletion; replay serves only t
t('e2e: only svc:chat can redact Chat’s events', async () => {
t('e2e: only svc:chat can redact Chat’s events', async () => {
if (!haveEvents) return;
if (!haveEvents) return;
- const id = say('not yours to delete');
+ const id = await say('not yours to delete');
await h.eventsRelay.flush();
await h.eventsRelay.flush();
const created = (await events.store.scan(0, { patterns: ['chat.message.created'], limit: 1000 })).rows.find((r) => r.subject_id === String(id));
const created = (await events.store.scan(0, { patterns: ['chat.message.created'], limit: 1000 })).rows.find((r) => r.subject_id === String(id));
- const env = deletions(0).at(-1); // a well-formed deletion, re-sourced by another service
+ const env = (await deletions(0)).at(-1); // a well-formed deletion, re-sourced by another service
const post = (sub, body) => fetch(`http://127.0.0.1:${eventsPort}/api/v1/events`, {
const post = (sub, body) => fetch(`http://127.0.0.1:${eventsPort}/api/v1/events`, {
method: 'POST', body: JSON.stringify(body),
method: 'POST', body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${h.serviceToken(['events.event.publish'], { aud: 'openvibe.events', sub })}` },
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${h.serviceToken(['events.event.publish'], { aud: 'openvibe.events', sub })}` },
@@ -260,7 +260,7 @@ t('e2e: only svc:chat can redact Chat’s events', async () => {
@@ -260,7 +260,7 @@ t('e2e: only svc:chat can redact Chat’s events', async () => {
r = await post('svc:live', { ...env, event_id: 'evt_01J9DDDDDDDDDDDDDDDDDDDDDD', payload: { redacts: { subject_type: 'chat_message', subject_ids: [String(id)] } } });
r = await post('svc:live', { ...env, event_id: 'evt_01J9DDDDDDDDDDDDDDDDDDDDDD', payload: { redacts: { subject_type: 'chat_message', subject_ids: [String(id)] } } });
assert.strictEqual(r.status, 403, 'svc:live cannot publish as chat');
assert.strictEqual(r.status, 403, 'svc:live cannot publish as chat');
assert.ok((await events.store.getEvent(created.id)).payload.includes('not yours to delete'), 'still intact');
assert.ok((await events.store.getEvent(created.id)).payload.includes('not yours to delete'), 'still intact');
- h.db.deleteChatMessage(id, admin.id);
+ await h.db.deleteChatMessage(id, admin.id);
await h.eventsRelay.flush();
await h.eventsRelay.flush();
assert.ok((await events.store.getEvent(created.id)).redacted_at, 'Chat itself can');
assert.ok((await events.store.getEvent(created.id)).redacted_at, 'Chat itself can');
});
});
@@ -15,7 +15,7 @@ const assert = require('assert');
@@ -15,7 +15,7 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { spawn } = require('child_process');
const { spawn } = require('child_process');
-const Database = require('better-sqlite3');
+const { openSqlite } = require('../scripts/lib/sqlite');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
const t = suite('migrate-preferences');
const t = suite('migrate-preferences');
@@ -42,22 +42,22 @@ t('a Live database with every kind of row', async () => {
@@ -42,22 +42,22 @@ t('a Live database with every kind of row', async () => {
h = await boot();
h = await boot();
mig = require('../server/prefs/from-live');
mig = require('../server/prefs/from-live');
livePath = path.join(h.tmp, 'live.db');
livePath = path.join(h.tmp, 'live.db');
- const live = new Database(livePath);
+ const live = openSqlite(livePath, { readonly: false, create: true });
live.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT);
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 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);`);
CREATE TABLE user_preferences (user_id INTEGER PRIMARY KEY, chat_settings TEXT DEFAULT '{}', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
- const add = (id, name, settings, links) => {
- live.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, name);
- for (const [service, subject] of links) live.prepare('INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, ?, ?, ?)').run(id, service, `x${id}`, subject);
- live.prepare('INSERT INTO user_preferences (user_id, chat_settings) VALUES (?, ?)').run(id, typeof settings === 'string' ? settings : JSON.stringify(settings));
+ 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));
};
};
- add(1, 'ann', { ...liveDefaults, showTimestamps: true, fontSize: 'large', showBadges: false }, [['network', S.ann]]);
- add(2, 'bob', liveDefaults, [['network', S.bob]]); // defaults only
- add(3, 'cat', { ...liveDefaults, compactMode: true }, []); // no subject
- add(4, 'dan', '{not json', [['network', 'usr_01J9DAN0000000000000000AAA']]); // unreadable
- add(5, 'eve', { ...liveDefaults, compactMode: true, fontSize: 'small' }, [['network', S.eve]]); // has a record already
- add(6, 'fay', { compactMode: true }, [['network', S.fay]]); // a partial old blob
- add(7, 'gus', { ...liveDefaults, fontSize: 'small' }, [['hobostreamer', S.gusOld], ['network', S.gus]]); // the network link wins
+ 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();
live.close();
for (const s of Object.values(S)) h.netModules.subjects.add(s);
for (const s of Object.values(S)) h.netModules.subjects.add(s);
h.netModules.set(NS, S.eve, { hide_emotes: true });
h.netModules.set(NS, S.eve, { hide_emotes: true });
@@ -72,7 +72,7 @@ t('the mapping keeps choices only', () => {
@@ -72,7 +72,7 @@ t('the mapping keeps choices only', () => {
});
});
t('plan: counts and targets from Live, read-only', () => {
t('plan: counts and targets from Live, read-only', () => {
- const live = new Database(livePath, { readonly: true });
+ const live = openSqlite(livePath);
const p = mig.plan(live);
const p = mig.plan(live);
live.close();
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.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 } });
@@ -107,7 +107,7 @@ t('dry run through the real script: a report, reads only', async () => {
@@ -107,7 +107,7 @@ t('dry run through the real script: a report, reads only', async () => {
t('--apply --backup: backup first (0600), create-only writes, revisions recorded', async () => {
t('--apply --backup: backup first (0600), create-only writes, revisions recorded', async () => {
const backup = path.join(h.tmp, 'prefs-backup-1.json');
const backup = path.join(h.tmp, 'prefs-backup-1.json');
- const live = new Database(livePath, { readonly: true });
+ const live = openSqlite(livePath);
const planned = mig.plan(live);
const planned = mig.plan(live);
live.close();
live.close();
await assert.rejects(mig.migrate(planned, { apply: true }), /--backup/);
await assert.rejects(mig.migrate(planned, { apply: true }), /--backup/);
@@ -26,7 +26,6 @@ const assert = require('assert');
@@ -26,7 +26,6 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
const compat = require('openvibe-shared/release-compat');
const compat = require('openvibe-shared/release-compat');
const h = require('./n-1/harness');
const h = require('./n-1/harness');
const svc = require('./n-1/service');
const svc = require('./n-1/service');
@@ -34,21 +33,21 @@ const svc = require('./n-1/service');
@@ -34,21 +33,21 @@ const svc = require('./n-1/service');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const FIX = path.join(__dirname, 'fixtures', 'n-1');
const FIX = path.join(__dirname, 'fixtures', 'n-1');
-// ── Skipped for this release only: the SQLite→PostgreSQL engine switch (plan T3) ────────────────
-// This release moves Chat's storage to PostgreSQL (/ready says pglite or postgresql, never a SQLite
-// file), so the N-1 fixture — the previous release's statements, collected from its *SQLite* schema and
-// dialect — can no longer be prepared or replayed here. The harness and the fixtures stay; after this
-// release deploys, `npm run n-1:record` records the previous release's statements from its PostgreSQL
-// schema and the replay resumes. Until then the run reports itself skipped (the ○ convention: it is
-// neither green nor a failure). Do not delete the harness.
-const ENGINE_SWITCH_TO_POSTGRES = true;
-if (ENGINE_SWITCH_TO_POSTGRES) {
+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)');
console.log('n-1: skipped (engine switch to PostgreSQL: N-1 re-recorded after this release deploys)');
process.exit(0);
process.exit(0);
}
}
-
-const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));
-const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));
+const { createDb } = require('openvibe-sdk/db');
+const quiet = { log() {}, info() {}, warn() {}, error: (...a) => console.error(...a) };
let failures = 0;
let failures = 0;
async function check(name, fn) {
async function check(name, fn) {
@@ -77,36 +76,38 @@ async function check(name, fn) {
@@ -77,36 +76,38 @@ async function check(name, fn) {
assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: '2' }] }), ['streams[].viewer_count was number, now string', 'total is gone']);
assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: '2' }] }), ['streams[].viewer_count was number, now string', 'total is gone']);
assert.ok(h.statusCompatible(200, 201) && h.statusCompatible(502, 503) && !h.statusCompatible(401, 403) && !h.statusCompatible(502, 404) && !h.statusCompatible(200, 404));
assert.ok(h.statusCompatible(200, 201) && h.statusCompatible(502, 503) && !h.statusCompatible(401, 403) && !h.statusCompatible(502, 404) && !h.statusCompatible(200, 404));
});
});
- await check('SQL: a dropped column fails the prepare; a new NOT NULL column without a default fails an old INSERT', () => {
- const d = new Database(':memory:');
- d.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT NOT NULL, c TEXT NOT NULL DEFAULT \'x\')');
- assert.deepStrictEqual(h.prepareProblems(d, ['SELECT a, gone FROM t']).map((p) => p.error), ['no such column: gone']);
- assert.deepStrictEqual(h.insertProblems(d, ['INSERT INTO t (a, b) VALUES (?, ?)', 'INSERT INTO t (a) VALUES (?)']).map((p) => p.error), ['t.b is NOT NULL without a default, and N-1 does not set it']);
- d.close();
+ await check('SQL: a dropped column fails the prepare; a new NOT NULL column without a default fails an old INSERT', async () => {
+ const d = createDb({ pglite: true, log: quiet });
+ await d.query('CREATE TABLE t (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, a text, b text NOT NULL, c text NOT NULL DEFAULT \'x\')');
+ assert.deepStrictEqual((await h.pgPrepareProblems(d, ['SELECT a, gone FROM t'])).map((p) => p.error), ['column "gone" does not exist']);
+ assert.deepStrictEqual((await h.pgInsertProblems(d, ['INSERT INTO t (a, b) VALUES ($1, $2)', 'INSERT INTO t (a) VALUES ($1)'])).map((p) => p.error), ['t.b is NOT NULL without a default, and N-1 does not set it']);
+ await d.close();
});
});
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-test-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-test-'));
- const dbPath = path.join(tmp, 'db', `${svc.service}.db`);
+ const dbPath = path.join(tmp, 'db', 'pglite');
const dataDir = path.join(tmp, 'data');
const dataDir = path.join(tmp, 'data');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
let server = null;
let server = null;
try {
try {
console.log(`n-1: this release on a database N-1 created`);
console.log(`n-1: this release on a database N-1 created`);
- await check('N-1\'s schema, ledger and first-use tables load, and this release migrates and seeds it', () => {
- const d = new Database(dbPath);
- d.transaction(() => {
- for (const ddl of worker.schema) d.exec(ddl);
- for (const [table, rows] of Object.entries(worker.ledger || {})) {
- for (const row of rows) {
- const cols = Object.keys(row);
- d.prepare(`INSERT INTO "${table}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...cols.map((c) => row[c]));
+ await check('N-1\'s schema (the migrations it applied) and ledger load, and this release migrates and seeds it', async () => {
+ const dir = path.join(tmp, 'n-1-migrations');
+ fs.mkdirSync(dir, { recursive: true });
+ for (const m of worker.migrations) fs.writeFileSync(path.join(dir, m.file), m.sql);
+ const d = createDb({ pglite: dbPath, log: quiet });
+ try {
+ await d.migrate({ dir, log: quiet });
+ await d.tx(async (t) => {
+ for (const [table, rows] of Object.entries(worker.ledger || {})) {
+ for (const row of rows) {
+ const cols = Object.keys(row);
+ await t.query(`INSERT INTO "${table}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(', ')})`, cols.map((c) => row[c]));
+ }
}
}
- }
- for (const ddl of worker.lazy || []) d.exec(ddl);
- })();
- d.pragma(`user_version = ${Number(worker.user_version) || 0}`);
- d.close();
+ });
+ } finally { await d.close(); }
svc.seed({ dir: ROOT, dbPath, dataDir });
svc.seed({ dir: ROOT, dbPath, dataDir });
});
});
server = await svc.boot({ dir: ROOT, dbPath, dataDir });
server = await svc.boot({ dir: ROOT, dbPath, dataDir });
@@ -139,10 +140,10 @@ async function check(name, fn) {
@@ -139,10 +140,10 @@ async function check(name, fn) {
server = null;
server = null;
console.log('n-1: N-1\'s SQL on the schema this release migrated');
console.log('n-1: N-1\'s SQL on the schema this release migrated');
- await check(`every statement N-1 runs still prepares (${worker.statements.length}), and none of its INSERTs misses a new required column`, () => {
- const d = new Database(dbPath, { readonly: true });
- const problems = [...h.prepareProblems(d, worker.statements), ...h.insertProblems(d, worker.statements)];
- d.close();
+ await check(`every statement N-1 runs still prepares (${worker.statements.length}), and none of its INSERTs misses a new required column`, async () => {
+ const d = createDb({ pglite: dbPath, log: quiet });
+ let problems;
+ try { problems = [...await h.pgPrepareProblems(d, worker.statements), ...await h.pgInsertProblems(d, worker.statements)]; } finally { await d.close(); }
assert.deepStrictEqual(problems.map((p) => `${p.error}: ${p.sql.slice(0, 160)}`), [], 'N-1 SQL that breaks on this schema (expand first, contract a release later: ADR-028)');
assert.deepStrictEqual(problems.map((p) => `${p.error}: ${p.sql.slice(0, 160)}`), [], 'N-1 SQL that breaks on this schema (expand first, contract a release later: ADR-028)');
});
});
} finally {
} finally {
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'use strict';
/**
/**
* Runs inside a Chat release (its directory is the cwd): boots it with that release's own
* Runs inside a Chat release (its directory is the cwd): boots it with that release's own
- * test/helpers.js (a stub Network and a stub Live, Chat in-process) on the database in N1_DB, seeds it,
+ * 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,
* and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it (Chat's own shutdown).
* and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it (Chat's own shutdown).
*/
*/
const path = require('path');
const path = require('path');
@@ -11,27 +12,27 @@ const path = require('path');
@@ -11,27 +12,27 @@ const path = require('path');
const quiet = () => {};
const quiet = () => {};
console.warn = quiet;
console.warn = quiet;
const helpers = require(path.join(dir, 'test', 'helpers'));
const helpers = require(path.join(dir, 'test', 'helpers'));
- const h = await helpers.boot({ env: { CHAT_DB_PATH: process.env.N1_DB } });
+ const h = await helpers.boot({ env: { CHAT_DB_PATH: process.env.N1_DB, CHAT_PGLITE_DIR: process.env.N1_DB } });
process.on('exit', () => { try { require('fs').rmSync(h.tmp, { recursive: true, force: true }); } catch { /* */ } });
process.on('exit', () => { try { require('fs').rmSync(h.tmp, { recursive: true, force: true }); } catch { /* */ } });
const star = h.addUser('n1star', { role: 'streamer' });
const star = h.addUser('n1star', { role: 'streamer' });
const fan = h.addUser('n1fan');
const fan = h.addUser('n1fan');
const channel = h.addChannel(star.id);
const channel = h.addChannel(star.id);
const stream = h.addStream(star.id, channel, { title: 'N-1 live stream' });
const stream = h.addStream(star.id, channel, { title: 'N-1 live stream' });
const db = require(path.join(dir, 'server', 'db', 'database'));
const db = require(path.join(dir, 'server', 'db', 'database'));
- db.saveChatMessage({ stream_id: stream, channel_user_id: star.id, user_id: fan.id, username: 'n1fan', message: 'hello from N-1' });
- db.saveChatMessage({ user_id: fan.id, username: 'n1fan', message: 'global hello from N-1', is_global: 1 });
+ await db.saveChatMessage({ stream_id: stream, channel_user_id: star.id, user_id: fan.id, username: 'n1fan', message: 'hello from N-1' });
+ await db.saveChatMessage({ user_id: fan.id, username: 'n1fan', message: 'global hello from N-1', is_global: 1 });
let room = null;
let room = null;
try {
try {
const rooms = require(path.join(dir, 'server', 'rooms', 'rooms'));
const rooms = require(path.join(dir, 'server', 'rooms', 'rooms'));
- room = rooms.create(star, { name: 'N-1 room', slug: 'n1-room' });
- rooms.post(rooms.bySlug('n1-room'), star, 'first post from N-1');
+ room = await rooms.create(star, { name: 'N-1 room', slug: 'n1-room' });
+ await rooms.post(await rooms.bySlug('n1-room'), star, 'first post from N-1');
} catch (err) { process.stderr.write(`[n-1] room seed: ${err.message}\n`); }
} catch (err) { process.stderr.write(`[n-1] room seed: ${err.message}\n`); }
let conversation = null;
let conversation = null;
try {
try {
const dm = require(path.join(dir, 'server', 'chat', 'dm'));
const dm = require(path.join(dir, 'server', 'chat', 'dm'));
- const c = dm.getOrCreateDirect(star.id, fan.id);
+ const c = await dm.getOrCreateDirect(star.id, fan.id);
conversation = c && (c.id || c.conversation_id || c);
conversation = c && (c.id || c.conversation_id || c);
- dm.sendMessage(conversation, fan.id, 'a direct hello from N-1');
+ await dm.sendMessage(conversation, fan.id, 'a direct hello from N-1');
} catch (err) { process.stderr.write(`[n-1] dm seed: ${err.message}\n`); }
} catch (err) { process.stderr.write(`[n-1] dm seed: ${err.message}\n`); }
process.stdout.write(`${JSON.stringify({ n1: { url: h.base, token: star.token, ids: { star: star.id, fan: fan.id, channel, stream, room: room && room.slug, conversation } } })}\n`);
process.stdout.write(`${JSON.stringify({ n1: { url: h.base, token: star.token, ids: { star: star.id, fan: fan.id, channel, stream, room: room && room.slug, conversation } } })}\n`);
})().catch((err) => { process.stderr.write(`${err.stack || err.message}\n`); process.exit(1); });
})().catch((err) => { process.stderr.write(`${err.stack || err.message}\n`); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+'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 };
@@ -812,6 +812,55 @@ function insertProblems(db, statements) {
@@ -812,6 +812,55 @@ function insertProblems(db, statements) {
return problems;
return problems;
}
}
+// ── PostgreSQL (a release on openvibe-sdk/db, plan T3) ─────────
+
+/**
+ * The migrations a release applied (ov_migrations), each with its file's text from `dir`: its schema, rebuilt
+ * exactly by applying them again (migrations are append-only and checksummed). → [{ file, sql }]
+ */
+async function pgMigrations(db, dir) {
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /^\d{4,}_.+\.sql$/.test(f)) : [];
+ const out = [];
+ for (const r of await db.many('SELECT id FROM ov_migrations ORDER BY id')) {
+ const file = files.find((f) => Number(f.split('_')[0]) === Number(r.id));
+ if (file) out.push({ file, sql: fs.readFileSync(path.join(dir, file), 'utf8') });
+ }
+ return out;
+}
+
+/** PREPAREs every statement on db (a missing table or column, or a type that no longer fits, fails). → [{ sql, error }] */
+async function pgPrepareProblems(db, statements) {
+ const problems = [];
+ for (const sql of statements) {
+ try {
+ await db.tx(async (t) => { await t.query(`PREPARE n1_check AS ${sql}`); await t.query('DEALLOCATE n1_check'); });
+ } catch (e) { problems.push({ sql, error: e.message }); }
+ }
+ return problems;
+}
+
+/** insertProblems on PostgreSQL: a column now NOT NULL without a default (nor identity, nor generated) that an N-1 INSERT does not name. */
+async function pgInsertProblems(db, statements) {
+ const problems = [];
+ const cols = new Map();
+ const required = async (table) => {
+ if (!cols.has(table)) {
+ cols.set(table, (await db.many(`SELECT column_name FROM information_schema.columns
+ WHERE table_schema = current_schema() AND table_name = $1 AND is_nullable = 'NO' AND column_default IS NULL
+ AND is_identity = 'NO' AND is_generated = 'NEVER'`, [table])).map((r) => r.column_name));
+ }
+ return cols.get(table);
+ };
+ for (const sql of statements) {
+ const m = /^\s*INSERT\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 = (await 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;
+}
+
// ── Git ──────────────────────────────────────────────────────
// ── Git ──────────────────────────────────────────────────────
/** A detached worktree of `ref` in a temp directory sharing root's node_modules → { dir, sha, remove() }. */
/** A detached worktree of `ref` in a temp directory sharing root's node_modules → { dir, sha, remove() }. */
@@ -852,4 +901,5 @@ module.exports = {
@@ -852,4 +901,5 @@ module.exports = {
wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
worktree, gitFiles, summarize,
worktree, gitFiles, summarize,
+ pgMigrations, pgPrepareProblems, pgInsertProblems,
};
};
@@ -2,14 +2,30 @@
@@ -2,14 +2,30 @@
/**
/**
* Loaded with `node -r` into a release booted by test/n-1/service.js (N-1 at record time, N in the
* Loaded with `node -r` into a release booted by test/n-1/service.js (N-1 at record time, N in the
* test), from that release's directory. With N1_SQL_OUT set, every SQL text the process prepares or
* test), from that release's directory. With N1_SQL_OUT set, every SQL text the process prepares or
- * executes on the chat database (CHAT_DB_PATH, read when the statement runs) is written there as JSON
- * when it exits.
+ * 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).
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const out = process.env.N1_SQL_OUT;
const out = process.env.N1_SQL_OUT;
-if (out) {
+const onPostgres = out && require('./engine').onPostgres(process.cwd());
+if (out && onPostgres) {
+ const sdkDb = require(require.resolve('openvibe-sdk/db', { paths: [process.cwd()] }));
+ const seen = new Set();
+ const createDb = sdkDb.createDb;
+ sdkDb.createDb = function (o) {
+ const d = createDb(o);
+ const query = d.query;
+ d.query = (q, values) => {
+ try { seen.add(typeof q === 'string' ? q : q.compile().text); } catch { /* */ }
+ return query(q, values);
+ };
+ 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 Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3'));
const seen = new Set();
const seen = new Set();
const note = (db, sql) => {
const note = (db, sql) => {
@@ -40,22 +40,22 @@ t('boot', async () => {
@@ -40,22 +40,22 @@ t('boot', async () => {
t('the projection keeps the newest revision per pair', async () => {
t('the projection keeps the newest revision per pair', async () => {
const nb = require('../server/chat/network-blocks');
const nb = require('../server/chat/network-blocks');
const e1 = event(bob.subject_id, alice.subject_id, true, 1);
const e1 = event(bob.subject_id, alice.subject_id, true, 1);
- assert.strictEqual(h.eventsConsumer.apply(e1).outcome, 'blocked');
- assert.strictEqual(h.eventsConsumer.apply(e1).duplicate, true, 'a redelivery is a no-op');
- assert.ok(nb.hasBlocked(bob.subject_id, alice.subject_id));
- assert.ok(!nb.hasBlocked(alice.subject_id, bob.subject_id), 'one direction');
- assert.ok(nb.eitherBlockedUsers(alice.id, bob.id) && nb.eitherBlockedUsers(bob.id, alice.id));
+ assert.strictEqual((await h.eventsConsumer.apply(e1)).outcome, 'blocked');
+ assert.strictEqual((await h.eventsConsumer.apply(e1)).duplicate, true, 'a redelivery is a no-op');
+ assert.ok(await nb.hasBlocked(bob.subject_id, alice.subject_id));
+ assert.ok(!await nb.hasBlocked(alice.subject_id, bob.subject_id), 'one direction');
+ assert.ok(await nb.eitherBlockedUsers(alice.id, bob.id) && await nb.eitherBlockedUsers(bob.id, alice.id));
// Out of order: revision 3 (unblock) then the late revision 2 (block) changes nothing.
// Out of order: revision 3 (unblock) then the late revision 2 (block) changes nothing.
- assert.strictEqual(h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, false, 3)).outcome, 'unblocked');
- assert.strictEqual(h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 2)).outcome, 'unchanged', 'an older revision never wins');
- assert.ok(!nb.hasBlocked(dave.subject_id, carol.subject_id));
+ assert.strictEqual((await h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, false, 3))).outcome, 'unblocked');
+ assert.strictEqual((await h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 2))).outcome, 'unchanged', 'an older revision never wins');
+ assert.ok(!await nb.hasBlocked(dave.subject_id, carol.subject_id));
// Only Network's, only valid payloads.
// Only Network's, only valid payloads.
- assert.strictEqual(h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 9, { source: 'live' })).outcome, 'ignored:source');
+ assert.strictEqual((await h.eventsConsumer.apply(event(dave.subject_id, carol.subject_id, true, 9, { source: 'live' }))).outcome, 'ignored:source');
const bad = event(dave.subject_id, carol.subject_id, true, 9); bad.payload.blocked = 'carol';
const bad = event(dave.subject_id, carol.subject_id, true, 9); bad.payload.blocked = 'carol';
- assert.strictEqual(h.eventsConsumer.apply(bad).outcome, 'ignored:payload');
+ assert.strictEqual((await h.eventsConsumer.apply(bad)).outcome, 'ignored:payload');
const zero = event(dave.subject_id, carol.subject_id, true, 0);
const zero = event(dave.subject_id, carol.subject_id, true, 0);
- assert.strictEqual(h.eventsConsumer.apply(zero).outcome, 'ignored:payload', 'revision starts at 1');
- assert.ok(!nb.hasBlocked(dave.subject_id, carol.subject_id));
+ assert.strictEqual((await h.eventsConsumer.apply(zero)).outcome, 'ignored:payload', 'revision starts at 1');
+ assert.ok(!await nb.hasBlocked(dave.subject_id, carol.subject_id));
});
});
t('DMs: bob blocked alice on the network — refused both ways, same shapes as dm_blocks', async () => {
t('DMs: bob blocked alice on the network — refused both ways, same shapes as dm_blocks', async () => {
@@ -86,7 +86,7 @@ t('DMs: bob blocked alice on the network — refused both ways, same shapes as d
@@ -86,7 +86,7 @@ t('DMs: bob blocked alice on the network — refused both ways, same shapes as d
});
});
t('an unblock lifts it', async () => {
t('an unblock lifts it', async () => {
- assert.strictEqual(h.eventsConsumer.apply(event(bob.subject_id, alice.subject_id, false, 2)).outcome, 'unblocked');
+ assert.strictEqual((await h.eventsConsumer.apply(event(bob.subject_id, alice.subject_id, false, 2))).outcome, 'unblocked');
let r = await h.http('POST', `/api/dm/conversations/${convAB}/messages`, { token: alice.token, body: { message: 'hi again' } });
let r = await h.http('POST', `/api/dm/conversations/${convAB}/messages`, { token: alice.token, body: { message: 'hi again' } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
r = await h.http('POST', `/api/dm/conversations/${group}/participants`, { token: carol.token, body: { user_id: alice.id } });
r = await h.http('POST', `/api/dm/conversations/${group}/participants`, { token: carol.token, body: { user_id: alice.id } });
@@ -26,7 +26,7 @@ t('boot', async () => {
@@ -26,7 +26,7 @@ t('boot', async () => {
alice = h.addUser('alice', { subject: ids.newId('user') });
alice = h.addUser('alice', { subject: ids.newId('user') });
carol = h.addUser('carol', { subject: ids.newId('user'), role: 'global_mod' });
carol = h.addUser('carol', { subject: ids.newId('user'), role: 'global_mod' });
modSubject = ids.newId('user');
modSubject = ids.newId('user');
- for (const u of [alice, carol]) h.ctx.upsertUser(h.live.users.get(u.id));
+ for (const u of [alice, carol]) await h.ctx.upsertUser(h.live.users.get(u.id));
});
});
t('a Network token for a projected subject resolves here, without Live', async () => {
t('a Network token for a projected subject resolves here, without Live', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'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.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { suite } = require('./helpers');
+
+const t = suite('no-sqlite');
+const ROOT = path.join(__dirname, '..');
+
+function files(dir) {
+ const out = [];
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) out.push(...files(p));
+ else if (/\.(js|mjs|cjs)$/.test(e.name)) out.push(p);
+ }
+ return out;
+}
+
+t('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('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('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.run();
@@ -45,8 +45,8 @@ t('a REST fallback message, a DM and a moderation action each leave one envelope
@@ -45,8 +45,8 @@ t('a REST fallback message, a DM and a moderation action each leave one envelope
assert.deepStrictEqual(sent.body, { ok: true });
assert.deepStrictEqual(sent.body, { ok: true });
const conv = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
const conv = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
await h.http('POST', `/api/dm/conversations/${conv.body.conversation.id}/messages`, { token: alice.token, body: { message: 'dm text' } });
await h.http('POST', `/api/dm/conversations/${conv.body.conversation.id}/messages`, { token: alice.token, body: { message: 'dm text' } });
- h.db.logModerationAction({ scope_type: 'site', actor_user_id: alice.id, target_user_id: bob.id, action_type: 'site_timeout', details: { duration: 60 } });
- const rows = h.db.all('SELECT event_type, event, sent_at FROM events_outbox ORDER BY seq');
+ await h.db.logModerationAction({ scope_type: 'site', actor_user_id: alice.id, target_user_id: bob.id, action_type: 'site_timeout', details: { duration: 60 } });
+ const rows = await h.db.all('SELECT event_type, event, sent_at FROM events_outbox ORDER BY seq');
assert.deepStrictEqual(rows.map((r) => r.event_type), ['chat.message.created', 'chat.dm.created', 'chat.moderation.action']);
assert.deepStrictEqual(rows.map((r) => r.event_type), ['chat.message.created', 'chat.dm.created', 'chat.moderation.action']);
for (const r of rows) {
for (const r of rows) {
const env = JSON.parse(r.event);
const env = JSON.parse(r.event);
@@ -70,21 +70,21 @@ t('a REST fallback message, a DM and a moderation action each leave one envelope
@@ -70,21 +70,21 @@ t('a REST fallback message, a DM and a moderation action each leave one envelope
t('Events down: rows wait with the error; back up: published once, marked sent', async () => {
t('Events down: rows wait with the error; back up: published once, marked sent', async () => {
eventsMode = 'down';
eventsMode = 'down';
await h.eventsRelay.flush();
await h.eventsRelay.flush();
- const waiting = h.db.all('SELECT attempts, last_error, sent_at FROM events_outbox');
+ const waiting = await h.db.all('SELECT attempts, last_error, sent_at FROM events_outbox');
assert.ok(waiting.every((r) => r.attempts === 1 && /503/.test(r.last_error) && r.sent_at === null));
assert.ok(waiting.every((r) => r.attempts === 1 && /503/.test(r.last_error) && r.sent_at === null));
eventsMode = 'ok';
eventsMode = 'ok';
const r = await h.eventsRelay.flush();
const r = await h.eventsRelay.flush();
assert.strictEqual(r.sent, 3);
assert.strictEqual(r.sent, 3);
assert.strictEqual(received.length, 3);
assert.strictEqual(received.length, 3);
- assert.ok(h.db.all('SELECT sent_at FROM events_outbox').every((x) => x.sent_at));
+ assert.ok((await h.db.all('SELECT sent_at FROM events_outbox')).every((x) => x.sent_at));
assert.strictEqual((await h.eventsRelay.flush()).sent, 0, 'nothing twice');
assert.strictEqual((await h.eventsRelay.flush()).sent, 0, 'nothing twice');
assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.events' && x.scope === 'events.event.publish'));
assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.events' && x.scope === 'events.event.publish'));
});
});
t('mirror: every change to Chat’s tables reaches Live, newest state per row', async () => {
t('mirror: every change to Chat’s tables reaches Live, newest state per row', async () => {
- const id = Number(h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'to be deleted', message_type: 'chat' }).lastInsertRowid);
- h.db.deleteChatMessage(id, streamer.id);
- h.db.recordFirstChat(`user:${bob.id}`, streamer.id);
+ const id = Number((await h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'to be deleted', message_type: 'chat' })).lastInsertRowid);
+ await h.db.deleteChatMessage(id, streamer.id);
+ await h.db.recordFirstChat(`user:${bob.id}`, streamer.id);
await h.mirrorRelay.flush();
await h.mirrorRelay.flush();
const byTable = (tbl) => h.live.mirror.filter((c) => c.table === tbl);
const byTable = (tbl) => h.live.mirror.filter((c) => c.table === tbl);
const msgChanges = byTable('chat_messages').filter((c) => c.row && c.row.id === id);
const msgChanges = byTable('chat_messages').filter((c) => c.row && c.row.id === id);
@@ -95,10 +95,10 @@ t('mirror: every change to Chat’s tables reaches Live, newest state per row',
@@ -95,10 +95,10 @@ t('mirror: every change to Chat’s tables reaches Live, newest state per row',
assert.ok(byTable('dm_messages').length === 1 && byTable('dm_participants').length === 2 && byTable('dm_conversations').length >= 1);
assert.ok(byTable('dm_messages').length === 1 && byTable('dm_participants').length === 2 && byTable('dm_conversations').length >= 1);
assert.ok(byTable('moderation_actions').length === 1);
assert.ok(byTable('moderation_actions').length === 1);
assert.deepStrictEqual(byTable('stream_first_chats')[0].row.chatter_key, `user:${bob.id}`);
assert.deepStrictEqual(byTable('stream_first_chats')[0].row.chatter_key, `user:${bob.id}`);
- assert.strictEqual(h.mirrorRelay.pending(), 0);
+ assert.strictEqual(await h.mirrorRelay.pending(), 0);
// A delete travels as a delete.
// A delete travels as a delete.
- const dmId = h.db.get('SELECT id FROM dm_messages').id;
- const conv = h.db.get('SELECT conversation_id FROM dm_messages').conversation_id;
+ const dmId = (await h.db.get('SELECT id FROM dm_messages')).id;
+ const conv = (await h.db.get('SELECT conversation_id FROM dm_messages')).conversation_id;
assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${conv}/messages/${dmId}`, { token: alice.token })).status, 200);
assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${conv}/messages/${dmId}`, { token: alice.token })).status, 200);
await h.mirrorRelay.flush();
await h.mirrorRelay.flush();
assert.deepStrictEqual(h.live.mirror.at(-1), { table: 'dm_messages', op: 'delete', pk: { id: dmId } });
assert.deepStrictEqual(h.live.mirror.at(-1), { table: 'dm_messages', op: 'delete', pk: { id: dmId } });
@@ -106,7 +106,7 @@ t('mirror: every change to Chat’s tables reaches Live, newest state per row',
@@ -106,7 +106,7 @@ t('mirror: every change to Chat’s tables reaches Live, newest state per row',
t('mirror: Live refusing (not yet CHAT_AUTHORITY=chat) keeps the queue; accepted later', async () => {
t('mirror: Live refusing (not yet CHAT_AUTHORITY=chat) keeps the queue; accepted later', async () => {
h.live.mirrorStatus = 409;
h.live.mirrorStatus = 409;
- h.db.recordRelayUser('twitch', 'Zed');
+ await h.db.recordRelayUser('twitch', 'Zed');
const r = await h.mirrorRelay.flush();
const r = await h.mirrorRelay.flush();
assert.ok(r.pending >= 1 && /409/.test(r.error));
assert.ok(r.pending >= 1 && /409/.test(r.error));
h.live.mirrorStatus = 200;
h.live.mirrorStatus = 200;
@@ -120,17 +120,18 @@ t('scripts/mirror-flush.js drains the queue without the service (rollback)', asy
@@ -120,17 +120,18 @@ t('scripts/mirror-flush.js drains the queue without the service (rollback)', asy
const { spawn } = require('child_process');
const { spawn } = require('child_process');
const path = require('path');
const path = require('path');
h.live.mirrorStatus = 409;
h.live.mirrorStatus = 409;
- h.db.recordRelayUser('twitch', 'Late');
+ await h.db.recordRelayUser('twitch', 'Late');
await h.mirrorRelay.flush();
await h.mirrorRelay.flush();
- assert.ok(h.mirrorRelay.pending() >= 1);
+ assert.ok(await h.mirrorRelay.pending() >= 1);
h.live.mirrorStatus = 200;
h.live.mirrorStatus = 200;
+ const env = { ...process.env, ...(await h.childDbEnv()) };
const r = await new Promise((resolve) => {
const r = await new Promise((resolve) => {
- const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'mirror-flush.js')], { env: { ...process.env } });
+ const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'mirror-flush.js')], { env });
let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { out += d; }); c.on('close', (code) => resolve({ code, out }));
let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { out += d; }); c.on('close', (code) => resolve({ code, out }));
});
});
assert.strictEqual(r.code, 0, r.out);
assert.strictEqual(r.code, 0, r.out);
assert.strictEqual(JSON.parse(r.out.slice(r.out.indexOf('{'))).pending, 0);
assert.strictEqual(JSON.parse(r.out.slice(r.out.indexOf('{'))).pending, 0);
- assert.strictEqual(h.mirrorRelay.pending(), 0);
+ assert.strictEqual(await h.mirrorRelay.pending(), 0);
assert.strictEqual(h.live.mirror.at(-1).row.username, 'late');
assert.strictEqual(h.live.mirror.at(-1).row.username, 'late');
});
});
@@ -48,9 +48,9 @@ const driver = {
@@ -48,9 +48,9 @@ const driver = {
connect: ({ role, stream }) => h.ws({ ip: IPS[role], stream }),
connect: ({ role, stream }) => h.ws({ ip: IPS[role], stream }),
http: (method, path, { token, body } = {}) => h.http(method, path, { token, body }),
http: (method, path, { token, body } = {}) => h.http(method, path, { token, body }),
// A network block, as the Events consumer applies network.block.changed.
// A network block, as the Events consumer applies network.block.changed.
- block: (blocker, blocked, active) => {
+ block: async (blocker, blocked, active) => {
const subject = { a: alice, b: bob, mod, streamer }[blocker].subject_id, target = { a: alice, b: bob, mod, streamer }[blocked].subject_id;
const subject = { a: alice, b: bob, mod, streamer }[blocker].subject_id, target = { a: alice, b: bob, mod, streamer }[blocked].subject_id;
- require('../server/chat/network-blocks').apply({ blocker: subject, blocked: target, active, revision: ++blockRev });
+ await require('../server/chat/network-blocks').apply({ blocker: subject, blocked: target, active, revision: ++blockRev });
},
},
};
};
@@ -62,7 +62,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sy
@@ -62,7 +62,7 @@ t('boot: a channel with a moderator, a live stream, a channel sound, TTS that sy
bob = h.addUser('bob', { subject: 'usr_01J9PAR1TY00000000000000B1' });
bob = h.addUser('bob', { subject: 'usr_01J9PAR1TY00000000000000B1' });
dave = h.addUser('dave');
dave = h.addUser('dave');
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
- h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)
+ await h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
h.live.subscribers.add(`${alice.id}|${streamer.id}`); // A subscribes to the channel; B does not
h.live.subscribers.add(`${alice.id}|${streamer.id}`); // A subscribes to the channel; B does not
await h.ctx.sync();
await h.ctx.sync();
@@ -104,8 +104,8 @@ t('dm: delivery keeps the participant rule (chatServer.sendDm checks dm.isPartic
@@ -104,8 +104,8 @@ t('dm: delivery keeps the participant rule (chatServer.sendDm checks dm.isPartic
const convId = conv.body.conversation.id;
const convId = conv.body.conversation.id;
const outsider = await p.join('mod', { stream: false });
const outsider = await p.join('mod', { stream: false });
const bobWs = await p.join('b', { stream: false });
const bobWs = await p.join('b', { stream: false });
- h.chatServer.sendDm(mod.id, { type: 'dm', conversation_id: convId, message: { message: 'not for you' } });
- h.chatServer.sendDm(bob.id, { type: 'dm', conversation_id: convId, message: { message: 'for bob' } });
+ await h.chatServer.sendDm(mod.id, { type: 'dm', conversation_id: convId, message: { message: 'not for you' } });
+ await h.chatServer.sendDm(bob.id, { type: 'dm', conversation_id: convId, message: { message: 'for bob' } });
await bobWs.next((m) => m.type === 'dm' && m.message.message === 'for bob');
await bobWs.next((m) => m.type === 'dm' && m.message.message === 'for bob');
assert.ok(await outsider.none((m) => m.type === 'dm'), 'a non-participant never receives it');
assert.ok(await outsider.none((m) => m.type === 'dm'), 'a non-participant never receives it');
await p.closeAll();
await p.closeAll();
@@ -197,7 +197,7 @@ t('sub-only fails closed: while Live cannot say whether someone subscribes they
@@ -197,7 +197,7 @@ t('sub-only fails closed: while Live cannot say whether someone subscribes they
t('slow mode is the channel’s saved setting: the dashboard’s value is enforced and announced, and a restart keeps it', async () => {
t('slow mode is the channel’s saved setting: the dashboard’s value is enforced and announced, and a restart keeps it', async () => {
const a = await p.join('a'); const b = await p.join('b');
const a = await p.join('a'); const b = await p.join('b');
// The dashboard saves 3 s in Chat's own settings row, which announces the change at once.
// The dashboard saves 3 s in Chat's own settings row, which announces the change at once.
- h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 3 });
+ await h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 3 });
assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, 3, 'the room is told');
assert.strictEqual((await a.next((m) => m.type === 'slowmode')).seconds, 3, 'the room is told');
await p.system(b, 'Slow mode enabled: 3s between messages');
await p.system(b, 'Slow mode enabled: 3s between messages');
const slowed = async (ws, label) => {
const slowed = async (ws, label) => {
@@ -216,7 +216,7 @@ t('slow mode is the channel’s saved setting: the dashboard’s value is enforc
@@ -216,7 +216,7 @@ t('slow mode is the channel’s saved setting: the dashboard’s value is enforc
assert.strictEqual(again.auth.slowmode_seconds, 3, 'read back after a restart');
assert.strictEqual(again.auth.slowmode_seconds, 3, 'read back after a restart');
await slowed(again, 'after a restart');
await slowed(again, 'after a restart');
// The dashboard turns it off.
// The dashboard turns it off.
- h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 0 });
+ await h.db.upsertChannelModerationSettings(channelId, { slow_mode_seconds: 0 });
assert.strictEqual((await b.next((m) => m.type === 'slowmode' && m.seconds === 0)).seconds, 0);
assert.strictEqual((await b.next((m) => m.type === 'slowmode' && m.seconds === 0)).seconds, 0);
await p.system(b, 'Slow mode disabled.');
await p.system(b, 'Slow mode disabled.');
await p.closeAll();
await p.closeAll();
@@ -273,11 +273,11 @@ t('the CLI: a dry run lists every scenario and opens nothing; --apply refuses ac
@@ -273,11 +273,11 @@ t('the CLI: a dry run lists every scenario and opens nothing; --apply refuses ac
assert.strictEqual(h.chatServer.clients.size, sockets, 'no connection');
assert.strictEqual(h.chatServer.clients.size, sockets, 'no connection');
assert.strictEqual(h.live.requests.length, requests, 'nothing reached Chat');
assert.strictEqual(h.live.requests.length, requests, 'nothing reached Chat');
// --apply: moddy and the channel owner are not named → nothing runs.
// --apply: moddy and the channel owner are not named → nothing runs.
- const before = h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n;
+ const before = (await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n;
out.length = 0;
out.length = 0;
assert.strictEqual(await parity.main(['--apply'], env, (l) => out.push(l)), 2);
assert.strictEqual(await parity.main(['--apply'], env, (l) => out.push(l)), 2);
assert.match(out.join('\n'), /refused: moddy, streamer are not named in OV_PARITY_TEST_ACCOUNTS; nothing was run/);
assert.match(out.join('\n'), /refused: moddy, streamer are not named in OV_PARITY_TEST_ACCOUNTS; nothing was run/);
- assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n, before, 'no message was written');
+ assert.strictEqual((await h.db.get('SELECT COUNT(*) AS n FROM chat_messages')).n, before, 'no message was written');
// Named: the CLI's own network client runs a scenario end to end.
// Named: the CLI's own network client runs a scenario end to end.
out.length = 0;
out.length = 0;
assert.strictEqual(await parity.main(['--apply', '--only', 'join,send'], { ...env, OV_PARITY_TEST_ACCOUNTS: 'Alice, bob, moddy, streamer' }, (l) => out.push(l)), 0, out.join('\n'));
assert.strictEqual(await parity.main(['--apply', '--only', 'join,send'], { ...env, OV_PARITY_TEST_ACCOUNTS: 'Alice, bob, moddy, streamer' }, (l) => out.push(l)), 0, out.join('\n'));
@@ -82,7 +82,8 @@ t('a step that misses its own schedule is late on its own (not masked by the fas
@@ -82,7 +82,8 @@ t('a step that misses its own schedule is late on its own (not masked by the fas
});
});
t('database failing: 503 not_ready naming the db check', async () => {
t('database failing: 503 not_ready naming the db check', async () => {
- h.db.run('ALTER TABLE chat_messages RENAME TO chat_messages_away');
+ const owner = globalThis.__ovChatTestOwnerDb(); // the runtime role cannot change the schema
+ await owner.query('ALTER TABLE chat_messages RENAME TO chat_messages_away');
try {
try {
const { status, body } = await ready();
const { status, body } = await ready();
assert.strictEqual(status, 503);
assert.strictEqual(status, 503);
@@ -90,9 +91,9 @@ t('database failing: 503 not_ready naming the db check', async () => {
@@ -90,9 +91,9 @@ t('database failing: 503 not_ready naming the db check', async () => {
assert.strictEqual(body.status, 'not_ready');
assert.strictEqual(body.status, 'not_ready');
assert.deepStrictEqual(body.failed, ['db']);
assert.deepStrictEqual(body.failed, ['db']);
assert.strictEqual(body.checks.db.status, 'fail');
assert.strictEqual(body.checks.db.status, 'fail');
- assert.match(body.checks.db.error, /no such table/);
+ assert.match(body.checks.db.error, /does not exist/);
} finally {
} finally {
- h.db.run('ALTER TABLE chat_messages_away RENAME TO chat_messages');
+ await owner.query('ALTER TABLE chat_messages_away RENAME TO chat_messages');
}
}
const { status } = await ready();
const { status } = await ready();
assert.strictEqual(status, 200);
assert.strictEqual(status, 200);
@@ -113,9 +113,9 @@ t('DMs: groups, blocks, banned users, user search', async () => {
@@ -113,9 +113,9 @@ t('DMs: groups, blocks, banned users, user search', async () => {
t('chat history: global page and cursor delta, deleted and expired rows hidden', async () => {
t('chat history: global page and cursor delta, deleted and expired rows hidden', async () => {
const ids = [];
const ids = [];
- for (let i = 0; i < 5; i++) ids.push(Number(h.db.saveChatMessage({ stream_id: null, user_id: alice.id, username: 'Alice', message: `g${i}`, message_type: 'chat', is_global: true }).lastInsertRowid));
- h.db.deleteChatMessage(ids[1], staff.id);
- h.db.run("UPDATE chat_messages SET auto_delete_at = '2000-01-01 00:00:00' WHERE id = ?", [ids[2]]);
+ for (let i = 0; i < 5; i++) ids.push(Number((await h.db.saveChatMessage({ stream_id: null, user_id: alice.id, username: 'Alice', message: `g${i}`, message_type: 'chat', is_global: true })).lastInsertRowid));
+ await h.db.deleteChatMessage(ids[1], staff.id);
+ await h.db.run("UPDATE chat_messages SET auto_delete_at = '2000-01-01 00:00:00' WHERE id = ?", [ids[2]]);
const page = await h.http('GET', '/api/chat/global/history?limit=10');
const page = await h.http('GET', '/api/chat/global/history?limit=10');
const got = page.body.messages.map((m) => m.message);
const got = page.body.messages.map((m) => m.message);
assert.deepStrictEqual(got.filter((x) => /^g\d$/.test(x)), ['g0', 'g3', 'g4']);
assert.deepStrictEqual(got.filter((x) => /^g\d$/.test(x)), ['g0', 'g3', 'g4']);
@@ -131,20 +131,21 @@ t('chat history: global page and cursor delta, deleted and expired rows hidden',
@@ -131,20 +131,21 @@ t('chat history: global page and cursor delta, deleted and expired rows hidden',
const small = await h.http('GET', `/api/chat/global/history?after_id=0&limit=1`);
const small = await h.http('GET', `/api/chat/global/history?after_id=0&limit=1`);
assert.strictEqual(small.body.complete, false, 'bigger gap than the limit → take a fresh page');
assert.strictEqual(small.body.complete, false, 'bigger gap than the limit → take a fresh page');
// Channel filter by username (the owner of the source stream or the channel room).
// Channel filter by username (the owner of the source stream or the channel room).
- h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'in the stream', message_type: 'chat' });
+ await h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'in the stream', message_type: 'chat' });
const byChan = await h.http('GET', '/api/chat/global/history?username=streamer');
const byChan = await h.http('GET', '/api/chat/global/history?username=streamer');
assert.deepStrictEqual(byChan.body.messages.map((m) => m.message), ['in the stream']);
assert.deepStrictEqual(byChan.body.messages.map((m) => m.message), ['in the stream']);
assert.strictEqual(byChan.body.messages[0].stream_channel, 'streamer');
assert.strictEqual(byChan.body.messages[0].stream_channel, 'streamer');
// The stream and channel cursor reads name deletions in their room too.
// The stream and channel cursor reads name deletions in their room too.
- const inRoom = [0, 1].map((i) => Number(h.db.saveChatMessage({ stream_id: streamId, channel_user_id: streamer.id, user_id: bob.id, username: 'Bob', message: `s${i}`, message_type: 'chat' }).lastInsertRowid));
- h.db.deleteChatMessage(inRoom[0], staff.id);
+ const inRoom = [];
+ for (const i of [0, 1]) inRoom.push(Number((await h.db.saveChatMessage({ stream_id: streamId, channel_user_id: streamer.id, user_id: bob.id, username: 'Bob', message: `s${i}`, message_type: 'chat' })).lastInsertRowid));
+ await h.db.deleteChatMessage(inRoom[0], staff.id);
for (const p of [`/api/chat/${streamId}/history`, `/api/chat/${streamId}/history?scope=stream&`, `/api/chat/channel/${streamer.id}/history`]) {
for (const p of [`/api/chat/${streamId}/history`, `/api/chat/${streamId}/history?scope=stream&`, `/api/chat/channel/${streamer.id}/history`]) {
const d = await h.http('GET', `${p}${p.endsWith('&') ? '' : '?'}after_id=${inRoom[1]}`);
const d = await h.http('GET', `${p}${p.endsWith('&') ? '' : '?'}after_id=${inRoom[1]}`);
assert.strictEqual(d.status, 200, p);
assert.strictEqual(d.status, 200, p);
assert.deepStrictEqual(d.body.deleted_ids, [inRoom[0]], p);
assert.deepStrictEqual(d.body.deleted_ids, [inRoom[0]], p);
}
}
assert.deepStrictEqual((await h.http('GET', `/api/chat/channel/${streamer.id + 999}/history?after_id=${inRoom[1]}`)).body.deleted_ids, [], 'another channel');
assert.deepStrictEqual((await h.http('GET', `/api/chat/channel/${streamer.id + 999}/history?after_id=${inRoom[1]}`)).body.deleted_ids, [], 'another channel');
- h.db.deleteChatMessage(inRoom[1], staff.id); // the log counts below are about 'in the stream'
+ await h.db.deleteChatMessage(inRoom[1], staff.id); // the log counts below are about 'in the stream'
});
});
t('chat logs and search are gated like Live', async () => {
t('chat logs and search are gated like Live', async () => {
@@ -171,22 +172,22 @@ t('chat logs and search are gated like Live', async () => {
@@ -171,22 +172,22 @@ t('chat logs and search are gated like Live', async () => {
});
});
t('staff reading other people\'s logs is audited; your own chat exports; CSV cells are never formulas', async () => {
t('staff reading other people\'s logs is audited; your own chat exports; CSV cells are never formulas', async () => {
- const audits = () => h.db.all("SELECT action_type, actor_user_id, target_user_id FROM moderation_actions WHERE action_type LIKE 'chat_log_%' ORDER BY id");
- const before = audits().length;
+ const audits = async () => await h.db.all("SELECT action_type, actor_user_id, target_user_id FROM moderation_actions WHERE action_type LIKE 'chat_log_%' ORDER BY id");
+ const before = (await audits()).length;
await h.http('GET', `/api/chat/search?q=stream&user_id=${alice.id}`, { token: alice.token });
await h.http('GET', `/api/chat/search?q=stream&user_id=${alice.id}`, { token: alice.token });
await h.http('GET', `/api/chat/user/${alice.id}/history`, { token: alice.token });
await h.http('GET', `/api/chat/user/${alice.id}/history`, { token: alice.token });
await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}`, { token: streamer.token });
await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}`, { token: streamer.token });
- assert.strictEqual(audits().length, before, 'your own lines and your own stream are not audited');
+ assert.strictEqual((await audits()).length, before, 'your own lines and your own stream are not audited');
await h.http('GET', `/api/chat/search?q=stream&user_id=${bob.id}`, { token: staff.token });
await h.http('GET', `/api/chat/search?q=stream&user_id=${bob.id}`, { token: staff.token });
await h.http('GET', `/api/chat/user/${bob.id}/history`, { token: staff.token });
await h.http('GET', `/api/chat/user/${bob.id}/history`, { token: staff.token });
await h.http('GET', `/api/chat/user/${bob.id}/history?offset=50`, { token: staff.token });
await h.http('GET', `/api/chat/user/${bob.id}/history?offset=50`, { token: staff.token });
- h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: '=HYPERLINK("x")', message_type: 'chat' });
+ await h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: '=HYPERLINK("x")', message_type: 'chat' });
const admin = h.addUser('exporter', { role: 'admin' });
const admin = h.addUser('exporter', { role: 'admin' });
await h.ctx.sync();
await h.ctx.sync();
const ex = await h.http('GET', `/api/chat/admin/logs/export?streamId=${streamId}&format=csv`, { token: admin.token });
const ex = await h.http('GET', `/api/chat/admin/logs/export?streamId=${streamId}&format=csv`, { token: admin.token });
assert.strictEqual(ex.status, 200);
assert.strictEqual(ex.status, 200);
assert.ok(ex.text.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking message is text');
assert.ok(ex.text.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking message is text');
- const got = audits().slice(before);
+ const got = (await audits()).slice(before);
assert.deepStrictEqual(got.map((a) => a.action_type), ['chat_log_search', 'chat_log_view', 'chat_log_export'], 'search, first page viewed, export');
assert.deepStrictEqual(got.map((a) => a.action_type), ['chat_log_search', 'chat_log_view', 'chat_log_export'], 'search, first page viewed, export');
assert.deepStrictEqual(got.map((a) => a.actor_user_id), [staff.id, staff.id, admin.id]);
assert.deepStrictEqual(got.map((a) => a.actor_user_id), [staff.id, staff.id, admin.id]);
assert.deepStrictEqual(got.slice(0, 2).map((a) => a.target_user_id), [bob.id, bob.id]);
assert.deepStrictEqual(got.slice(0, 2).map((a) => a.target_user_id), [bob.id, bob.id]);
@@ -210,7 +211,7 @@ t('profile card and anon info come from Live', async () => {
@@ -210,7 +211,7 @@ t('profile card and anon info come from Live', async () => {
assert.ok('last_seen' in own.body);
assert.ok('last_seen' in own.body);
assert.strictEqual((await h.http('GET', '/api/chat/user/nobody/profile')).status, 404);
assert.strictEqual((await h.http('GET', '/api/chat/user/nobody/profile')).status, 404);
h.live.anon.set('203.0.113.99', { num: 42, first_seen: '2026-01-01 00:00:00' });
h.live.anon.set('203.0.113.99', { num: 42, first_seen: '2026-01-01 00:00:00' });
- h.db.saveChatMessage({ stream_id: null, anon_id: 'anon42', username: 'anon42', message: 'hi', message_type: 'chat', is_global: true });
+ await h.db.saveChatMessage({ stream_id: null, anon_id: 'anon42', username: 'anon42', message: 'hi', message_type: 'chat', is_global: true });
const a = await h.http('GET', '/api/chat/anon/anon42');
const a = await h.http('GET', '/api/chat/anon/anon42');
assert.deepStrictEqual({ ...a.body.anon, first_chat: !!a.body.anon.first_chat }, { anon_id: 'anon42', anon_num: 42, first_seen: '2026-01-01 00:00:00', first_chat: true, message_count: 1 });
assert.deepStrictEqual({ ...a.body.anon, first_chat: !!a.body.anon.first_chat }, { anon_id: 'anon42', anon_num: 42, first_seen: '2026-01-01 00:00:00', first_chat: true, message_count: 1 });
assert.strictEqual((await h.http('GET', '/api/chat/anon/bob')).status, 400);
assert.strictEqual((await h.http('GET', '/api/chat/anon/bob')).status, 400);
@@ -13,12 +13,11 @@
@@ -13,12 +13,11 @@
* real id — including a bridge broadcast whose insert was acknowledged before the restart).
* real id — including a bridge broadcast whose insert was acknowledged before the restart).
*/
*/
const assert = require('assert');
const assert = require('assert');
-const Database = require('better-sqlite3');
const path = require('path');
const path = require('path');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
const t = suite('restart-resume');
const t = suite('restart-resume');
-let h, chat, port, dbFile, streamer, posters, reader, channelId, streamId;
+let h, chat, port, streamer, posters, reader, channelId, streamId;
const REF = -(2 ** 40) - 1;
const REF = -(2 ** 40) - 1;
const BOOT = 'live-boot-restart';
const BOOT = 'live-boot-restart';
let BRIDGE;
let BRIDGE;
@@ -26,12 +25,9 @@ const sockets = [];
@@ -26,12 +25,9 @@ const sockets = [];
const calls = (ops) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: BOOT, ops: ops.map((o, i) => ({ seq: i + 1, ...o })) } });
const calls = (ops) => h.http('POST', '/internal/live/calls', { token: BRIDGE, body: { boot: BOOT, ops: ops.map((o, i) => ({ seq: i + 1, ...o })) } });
-// What the database says the rooms hold (a separate read-only connection: Chat is another process).
-function roomRows(where, params) {
- const d = new Database(dbFile, { readonly: true, fileMustExist: true });
- try {
- return d.prepare(`SELECT id, message, message_type FROM chat_messages WHERE is_deleted = 0 AND ${where} ORDER BY id`).all(...params);
- } finally { d.close(); }
+// What the database says the rooms hold (this process's own handle on the same database: Chat is another process).
+async function roomRows(where, params) {
+ return await h.db.getDb().prepare(`SELECT id, message, message_type FROM chat_messages WHERE is_deleted = 0 AND ${where} ORDER BY id`).all(...params);
}
}
/** A reader as Live's chat.js keeps one: shown rows by id and the cursor (the highest id shown). */
/** A reader as Live's chat.js keeps one: shown rows by id and the cursor (the highest id shown). */
@@ -78,9 +74,9 @@ async function post(user, ip, text) {
@@ -78,9 +74,9 @@ async function post(user, ip, text) {
return own.id;
return own.id;
}
}
-const streamRoom = { key: 'stream', rows: () => roomRows('channel_user_id = ?', [streamer.id]), delta: (after) => h.http('GET', `/api/chat/${streamId}/history?after_id=${after}`) };
-const channelRoom = { key: 'channel', rows: () => roomRows('channel_user_id = ?', [streamer.id]), delta: (after) => h.http('GET', `/api/chat/channel/${streamer.id}/history?after_id=${after}`) };
-const globalRoom = { key: 'global', rows: () => roomRows("message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation')", []), delta: (after) => h.http('GET', `/api/chat/global/history?after_id=${after}`) };
+const streamRoom = { key: 'stream', rows: async () => await roomRows('channel_user_id = ?', [streamer.id]), delta: (after) => h.http('GET', `/api/chat/${streamId}/history?after_id=${after}`) };
+const channelRoom = { key: 'channel', rows: async () => await roomRows('channel_user_id = ?', [streamer.id]), delta: (after) => h.http('GET', `/api/chat/channel/${streamer.id}/history?after_id=${after}`) };
+const globalRoom = { key: 'global', rows: async () => await roomRows("message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation')", []), delta: (after) => h.http('GET', `/api/chat/global/history?after_id=${after}`) };
const readers = {};
const readers = {};
@@ -92,7 +88,6 @@ t('boot the stubs, then Chat as its own process', async () => {
@@ -92,7 +88,6 @@ t('boot the stubs, then Chat as its own process', async () => {
posters = ['ann', 'ben', 'cat', 'dan', 'eve', 'fay'].map((n) => h.addUser(n));
posters = ['ann', 'ben', 'cat', 'dan', 'eve', 'fay'].map((n) => h.addUser(n));
channelId = h.addChannel(streamer.id);
channelId = h.addChannel(streamer.id);
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
- dbFile = path.join(h.tmp, 'chat.db');
await h.detach();
await h.detach();
port = await h.freePort();
port = await h.freePort();
chat = await h.spawnChat({ port });
chat = await h.spawnChat({ port });
@@ -136,7 +131,7 @@ t('the stream reader drops off; messages keep coming; Chat restarts (SIGTERM, ne
@@ -136,7 +131,7 @@ t('the stream reader drops off; messages keep coming; Chat restarts (SIGTERM, ne
// And one whose response Live never got: it will send it again after the restart.
// And one whose response Live never got: it will send it again after the restart.
const lost = await calls([{ op: 'db', ref: REF - 3, key: 'live:r4', args: ['saveChatMessage', { stream_id: streamId, username: 'ChatBot', message: 'bridge retried', message_type: 'chat', source_platform: 'ai' }] }]);
const lost = await calls([{ op: 'db', ref: REF - 3, key: 'live:r4', args: ['saveChatMessage', { stream_id: streamId, username: 'ChatBot', message: 'bridge retried', message_type: 'chat', source_platform: 'ai' }] }]);
assert.ok(lost.body.results[0].ok, lost.text);
assert.ok(lost.body.results[0].ok, lost.text);
- beforeMax = roomRows('1 = 1', []).reduce((m, x) => Math.max(m, x.id), 0);
+ beforeMax = (await roomRows('1 = 1', [])).reduce((m, x) => Math.max(m, x.id), 0);
const closed = new Promise((r) => readers.global.ws.on('close', r));
const closed = new Promise((r) => readers.global.ws.on('close', r));
const exit = await h.stopChat(chat);
const exit = await h.stopChat(chat);
@@ -155,7 +150,7 @@ t('after the restart: the split broadcast of an acknowledged insert, a retried i
@@ -155,7 +150,7 @@ t('after the restart: the split broadcast of an acknowledged insert, a retried i
const late = await calls([{ op: 'forwardToGlobal', args: [streamId, { type: 'chat', id: REF - 1, username: 'ChatBot', message: 'bridge across restart' }] }]);
const late = await calls([{ op: 'forwardToGlobal', args: [streamId, { type: 'chat', id: REF - 1, username: 'ChatBot', message: 'bridge across restart' }] }]);
assert.ok(late.body.results[0].ok, late.text);
assert.ok(late.body.results[0].ok, late.text);
const frame = await readers.global.ws.next((m) => m.type === 'chat' && m.message === 'bridge across restart');
const frame = await readers.global.ws.next((m) => m.type === 'chat' && m.message === 'bridge across restart');
- assert.strictEqual(frame.id, roomRows("message = 'bridge across restart'", [])[0].id, 'the placeholder maps to the real id after a restart');
+ assert.strictEqual(frame.id, (await roomRows("message = 'bridge across restart'", []))[0].id, 'the placeholder maps to the real id after a restart');
// The insert whose answer was lost comes again with its key and its broadcast: applied once,
// The insert whose answer was lost comes again with its key and its broadcast: applied once,
// and the broadcast carries the first insert's id.
// and the broadcast carries the first insert's id.
@@ -164,7 +159,7 @@ t('after the restart: the split broadcast of an acknowledged insert, a retried i
@@ -164,7 +159,7 @@ t('after the restart: the split broadcast of an acknowledged insert, a retried i
{ op: 'forwardToGlobal', args: [streamId, { type: 'chat', id: REF - 3, username: 'ChatBot', message: 'bridge retried' }] },
{ op: 'forwardToGlobal', args: [streamId, { type: 'chat', id: REF - 3, username: 'ChatBot', message: 'bridge retried' }] },
]);
]);
assert.ok(retry.body.results.every((x) => x.ok), retry.text);
assert.ok(retry.body.results.every((x) => x.ok), retry.text);
- const once = roomRows("message = 'bridge retried'", []);
+ const once = await roomRows("message = 'bridge retried'", []);
assert.strictEqual(once.length, 1, 'a retried insert is applied once across the restart');
assert.strictEqual(once.length, 1, 'a retried insert is applied once across the restart');
assert.strictEqual(retry.body.results[0].result.lastInsertRowid, once[0].id);
assert.strictEqual(retry.body.results[0].result.lastInsertRowid, once[0].id);
assert.strictEqual((await readers.global.ws.next((m) => m.type === 'chat' && m.message === 'bridge retried')).id, once[0].id);
assert.strictEqual((await readers.global.ws.next((m) => m.type === 'chat' && m.message === 'bridge retried')).id, once[0].id);
@@ -198,7 +193,7 @@ for (const [name, room] of [['stream', streamRoom], ['global', globalRoom], ['ch
@@ -198,7 +193,7 @@ for (const [name, room] of [['stream', streamRoom], ['global', globalRoom], ['ch
if (name === 'stream') await r.ws.next((m) => m.type === 'chat' && m.message === 'during reconnect');
if (name === 'stream') await r.ws.next((m) => m.type === 'chat' && m.message === 'during reconnect');
assert.deepStrictEqual(r.bad, [], `${name}: every frame carries its row's real id`);
assert.deepStrictEqual(r.bad, [], `${name}: every frame carries its row's real id`);
- const want = room.rows();
+ const want = await room.rows();
assert.deepStrictEqual([...r.shown.keys()].sort((a, b) => a - b), want.map((x) => x.id), `${name}: exactly the room's rows`);
assert.deepStrictEqual([...r.shown.keys()].sort((a, b) => a - b), want.map((x) => x.id), `${name}: exactly the room's rows`);
const seen = new Set();
const seen = new Set();
for (const text of r.texts) { assert.ok(!seen.has(text), `${name}: "${text}" shown twice`); seen.add(text); }
for (const text of r.texts) { assert.ok(!seen.has(text), `${name}: "${text}" shown twice`); seen.add(text); }
@@ -36,7 +36,7 @@ t('boot', async () => {
@@ -36,7 +36,7 @@ t('boot', async () => {
alice = h.addUser('alice', { subject: ids.newId('user') });
alice = h.addUser('alice', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
bot = h.addUser('helperbot', { subject: alice.subject_id, apiScopes: ['chat'] });
bot = h.addUser('helperbot', { subject: alice.subject_id, apiScopes: ['chat'] });
- for (const u of [alice, bob]) h.ctx.upsertUser(h.live.users.get(u.id));
+ for (const u of [alice, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
assert.ok(require('../server/events/consumer').TOPICS.includes('network.user.token_valid_after'), 'Chat subscribes to it');
assert.ok(require('../server/events/consumer').TOPICS.includes('network.user.token_valid_after'), 'Chat subscribes to it');
});
});
@@ -45,7 +45,7 @@ t('a sign-out everywhere closes the person\'s socket within 5 seconds; others st
@@ -45,7 +45,7 @@ t('a sign-out everywhere closes the person\'s socket within 5 seconds; others st
const b = await signedIn(tokenFor(bob));
const b = await signedIn(tokenFor(bob));
assert.strictEqual(a.auth.core_username, 'alice');
assert.strictEqual(a.auth.core_username, 'alice');
const started = Date.now();
const started = Date.now();
- const out = h.eventsConsumer.apply(event(alice.subject_id, Date.now() - 5000));
+ const out = await h.eventsConsumer.apply(event(alice.subject_id, Date.now() - 5000));
assert.strictEqual(out.outcome, 'revoked');
assert.strictEqual(out.outcome, 'revoked');
const c = await closed(a.ws);
const c = await closed(a.ws);
assert.strictEqual(c.code, 4001);
assert.strictEqual(c.code, 4001);
@@ -64,8 +64,8 @@ t('the old token is refused afterwards; a newer one works; a redelivery and an o
@@ -64,8 +64,8 @@ t('the old token is refused afterwards; a newer one works; a redelivery and an o
const fresh = await signedIn(tokenFor(alice, 0));
const fresh = await signedIn(tokenFor(alice, 0));
assert.strictEqual(fresh.auth.core_username, 'alice', 'a token issued after the cutoff works');
assert.strictEqual(fresh.auth.core_username, 'alice', 'a token issued after the cutoff works');
const ev = event(alice.subject_id, Date.now() - 120000, 'password_changed');
const ev = event(alice.subject_id, Date.now() - 120000, 'password_changed');
- assert.strictEqual(h.eventsConsumer.apply(ev).outcome, 'unchanged', 'an older cutoff never moves it back');
- assert.strictEqual(h.eventsConsumer.apply(ev).duplicate, true);
+ assert.strictEqual((await h.eventsConsumer.apply(ev)).outcome, 'unchanged', 'an older cutoff never moves it back');
+ assert.strictEqual((await h.eventsConsumer.apply(ev)).duplicate, true);
assert.strictEqual(fresh.ws.readyState, 1);
assert.strictEqual(fresh.ws.readyState, 1);
fresh.ws.close();
fresh.ws.close();
});
});
@@ -73,14 +73,14 @@ t('the old token is refused afterwards; a newer one works; a redelivery and an o
@@ -73,14 +73,14 @@ t('the old token is refused afterwards; a newer one works; a redelivery and an o
t('bots on an API token and events from anyone but Network are left alone', async () => {
t('bots on an API token and events from anyone but Network are left alone', async () => {
const b = await signedIn(bot.token);
const b = await signedIn(bot.token);
assert.strictEqual(b.auth.core_username, 'helperbot');
assert.strictEqual(b.auth.core_username, 'helperbot');
- assert.strictEqual(h.eventsConsumer.apply(event(alice.subject_id, Date.now() + 1000)).outcome, 'revoked');
+ assert.strictEqual((await h.eventsConsumer.apply(event(alice.subject_id, Date.now() + 1000))).outcome, 'revoked');
await new Promise((r) => setTimeout(r, 200));
await new Promise((r) => setTimeout(r, 200));
assert.strictEqual(b.ws.readyState, 1, 'API-token socket stays');
assert.strictEqual(b.ws.readyState, 1, 'API-token socket stays');
b.ws.close();
b.ws.close();
const forged = { ...event(bob.subject_id, Date.now()), source: 'live' };
const forged = { ...event(bob.subject_id, Date.now()), source: 'live' };
- assert.strictEqual(h.eventsConsumer.apply(forged).outcome, 'ignored:source');
+ assert.strictEqual((await h.eventsConsumer.apply(forged)).outcome, 'ignored:source');
const bad = event(bob.subject_id, Date.now()); bad.payload.valid_after = 'soon';
const bad = event(bob.subject_id, Date.now()); bad.payload.valid_after = 'soon';
- assert.strictEqual(h.eventsConsumer.apply(bad).outcome, 'ignored:payload');
+ assert.strictEqual((await h.eventsConsumer.apply(bad)).outcome, 'ignored:payload');
});
});
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });
@@ -65,7 +65,7 @@ t('boot (CHAT_CALLS=1)', async () => {
@@ -65,7 +65,7 @@ t('boot (CHAT_CALLS=1)', async () => {
own = mk('owner1'); md = mk('mod1'); spk = mk('speaker1'); par = mk('part1'); mem = mk('member1'); vw = mk('viewer1');
own = mk('owner1'); md = mk('mod1'); spk = mk('speaker1'); par = mk('part1'); mem = mk('member1'); vw = mk('viewer1');
blk = mk('blocked1'); out = mk('outsider1'); staff = mk('staff1', { role: 'global_mod' }); banned = mk('banned1');
blk = mk('blocked1'); out = mk('outsider1'); staff = mk('staff1', { role: 'global_mod' }); banned = mk('banned1');
bot = h.addUser('bot1', { apiScopes: ['chat'] });
bot = h.addUser('bot1', { apiScopes: ['chat'] });
- for (const u of [own, md, spk, par, mem, vw, blk, out, staff, banned, bot]) h.ctx.upsertUser(h.live.users.get(u.id));
+ for (const u of [own, md, spk, par, mem, vw, blk, out, staff, banned, bot]) await h.ctx.upsertUser(h.live.users.get(u.id));
});
});
t('the matrix: what every role may do in every kind, public and private', async () => {
t('the matrix: what every role may do in every kind, public and private', async () => {
@@ -80,31 +80,31 @@ t('the matrix: what every role may do in every kind, public and private', async
@@ -80,31 +80,31 @@ t('the matrix: what every role may do in every kind, public and private', async
for (const kind of ['community', 'call', 'system']) {
for (const kind of ['community', 'call', 'system']) {
for (const visibility of ['public', 'private']) {
for (const visibility of ['public', 'private']) {
const owner = kind === 'system' ? staff : own;
const owner = kind === 'system' ? staff : own;
- const room = rooms.bySlug(rooms.create(owner, { name: `M ${kind} ${visibility}`, kind, visibility }).slug);
+ const room = await rooms.bySlug((await rooms.create(owner, { name: `M ${kind} ${visibility}`, kind, visibility })).slug);
assert.deepStrictEqual(rooms.KIND_ROLES[kind], Object.keys(EXPECT[kind]), `${kind}: its roles`);
assert.deepStrictEqual(rooms.KIND_ROLES[kind], Object.keys(EXPECT[kind]), `${kind}: its roles`);
- for (const role of Object.keys(EXPECT[kind])) if (role !== 'owner') rooms.setRole(room, owner, byRole[role].id, role);
+ for (const role of Object.keys(EXPECT[kind])) if (role !== 'owner') await rooms.setRole(room, owner, byRole[role].id, role);
for (const [role, want] of Object.entries(EXPECT[kind])) {
for (const [role, want] of Object.entries(EXPECT[kind])) {
const who = role === 'owner' ? owner : byRole[role];
const who = role === 'owner' ? owner : byRole[role];
- const a = rooms.access(room, who);
+ const a = await rooms.access(room, who);
assert.strictEqual(a.role, role, `${kind}/${visibility}: ${who.username} is ${role}`);
assert.strictEqual(a.role, role, `${kind}/${visibility}: ${who.username} is ${role}`);
// A system room's owner is chat staff: they may post and manage anyway.
// A system room's owner is chat staff: they may post and manage anyway.
assert.strictEqual(flags(a), want, `${kind}/${visibility}/${role}`);
assert.strictEqual(flags(a), want, `${kind}/${visibility}/${role}`);
}
}
const pub = visibility === 'public';
const pub = visibility === 'public';
const listen = kind === 'call' && pub ? 'j' : '-';
const listen = kind === 'call' && pub ? 'j' : '-';
- assert.strictEqual(flags(rooms.access(room, out)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed in, no role`);
- assert.strictEqual(flags(rooms.access(room, null)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed out`);
- if (kind !== 'system') assert.strictEqual(flags(rooms.access(room, staff)), `r-${kind === 'call' ? 'jt' : '--'}mM`, `${kind}/${visibility}: chat staff without a role`);
- else assert.strictEqual(flags(rooms.access(room, staff)), 'rp--mM', 'system: staff post');
+ assert.strictEqual(flags(await rooms.access(room, out)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed in, no role`);
+ assert.strictEqual(flags(await rooms.access(room, null)), pub ? `r-${listen}---` : '------', `${kind}/${visibility}: signed out`);
+ if (kind !== 'system') assert.strictEqual(flags(await rooms.access(room, staff)), `r-${kind === 'call' ? 'jt' : '--'}mM`, `${kind}/${visibility}: chat staff without a role`);
+ else assert.strictEqual(flags(await rooms.access(room, staff)), 'rp--mM', 'system: staff post');
}
}
}
}
// A site ban: reading stays, posting and talking go, whatever the role.
// A site ban: reading stays, posting and talking go, whatever the role.
- const room = rooms.bySlug(rooms.create(own, { name: 'Banned Test', kind: 'call' }).slug);
- rooms.setRole(room, own, banned.id, 'speaker');
- assert.strictEqual(flags(rooms.access(room, banned)), 'rpjt--');
+ const room = await rooms.bySlug((await rooms.create(own, { name: 'Banned Test', kind: 'call' })).slug);
+ await rooms.setRole(room, own, banned.id, 'speaker');
+ assert.strictEqual(flags(await rooms.access(room, banned)), 'rpjt--');
h.live.addBan({ user_id: banned.id });
h.live.addBan({ user_id: banned.id });
await h.ctx.invalidateBans();
await h.ctx.invalidateBans();
- assert.strictEqual(flags(rooms.access(room, banned)), 'r-j---', 'banned: reads and listens only');
+ assert.strictEqual(flags(await rooms.access(room, banned)), 'r-j---', 'banned: reads and listens only');
h.live.clearBans();
h.live.clearBans();
await h.ctx.invalidateBans();
await h.ctx.invalidateBans();
});
});
@@ -140,7 +140,7 @@ t('roles belong to their kind, mods are the owner\'s, and joining gives the kind
@@ -140,7 +140,7 @@ t('roles belong to their kind, mods are the owner\'s, and joining gives the kind
assert.strictEqual((await api('POST', '/open-stage/members', md, { username: 'member1', role: 'mod' })).status, 403, 'only the owner appoints mods');
assert.strictEqual((await api('POST', '/open-stage/members', md, { username: 'member1', role: 'mod' })).status, 403, 'only the owner appoints mods');
assert.strictEqual((await api('POST', '/open-stage/members', par, { username: 'member1', role: 'viewer' })).status, 403, 'a speaker is not a moderator');
assert.strictEqual((await api('POST', '/open-stage/members', par, { username: 'member1', role: 'viewer' })).status, 403, 'a speaker is not a moderator');
await api('POST', '/open-stage/members', md, { username: 'part1', role: 'participant' });
await api('POST', '/open-stage/members', md, { username: 'part1', role: 'participant' });
- const log = h.db.all("SELECT action_type, details FROM moderation_actions WHERE scope_type = 'room' AND scope_id = 'open-stage' ORDER BY id").map((x) => x.action_type);
+ const log = (await h.db.all("SELECT action_type, details FROM moderation_actions WHERE scope_type = 'room' AND scope_id = 'open-stage' ORDER BY id")).map((x) => x.action_type);
assert.deepStrictEqual(log, ['room_mod_add', 'room_speaker_add', 'room_speaker_remove']);
assert.deepStrictEqual(log, ['room_mod_add', 'room_speaker_add', 'room_speaker_remove']);
// Posting follows the role: viewers and system-room viewers read only; staff post in system rooms.
// Posting follows the role: viewers and system-room viewers read only; staff post in system rooms.
@@ -237,8 +237,8 @@ t('a role change reaches the running call at once; a ban in the call blocks in t
@@ -237,8 +237,8 @@ t('a role change reaches the running call at once; a ban in the call blocks in t
assert.strictEqual(spkWs.welcome.canTalk, true);
assert.strictEqual(spkWs.welcome.canTalk, true);
ownWs.sendJson({ type: 'ban', targetPeerId: spkWs.welcome.peerId });
ownWs.sendJson({ type: 'ban', targetPeerId: spkWs.welcome.peerId });
await spkWs.closed;
await spkWs.closed;
- const room = rooms.bySlug('open-stage');
- assert.strictEqual(rooms.access(room, spk).role, 'blocked');
+ const room = await rooms.bySlug('open-stage');
+ assert.strictEqual((await rooms.access(room, spk)).role, 'blocked');
assert.strictEqual((await api('GET', '/open-stage', spk)).status, 404, 'blocked: the room is gone for them');
assert.strictEqual((await api('GET', '/open-stage', spk)).status, 404, 'blocked: the room is gone for them');
// Turning the room private drops people who are not members (a guest listening).
// Turning the room private drops people who are not members (a guest listening).
@@ -257,7 +257,7 @@ t('attachments: a manager attaches with their own token, idempotently; detach; n
@@ -257,7 +257,7 @@ t('attachments: a manager attaches with their own token, idempotently; detach; n
assert.deepStrictEqual([r.body.created, r.body.attachment.service, r.body.attachment.resource, r.body.room.slug], [true, 'community', 'general', 'tea-talk']);
assert.deepStrictEqual([r.body.created, r.body.attachment.service, r.body.attachment.resource, r.body.room.slug], [true, 'community', 'general', 'tea-talk']);
r = await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General' });
r = await api('POST', '/tea-talk/attachments', own, { service: 'community', resource: 'general', title: 'General' });
assert.deepStrictEqual([r.status, r.body.created], [200, false], 'the same attachment again is a no-op');
assert.deepStrictEqual([r.status, r.body.created], [200, false], 'the same attachment again is a no-op');
- assert.strictEqual(h.db.get("SELECT COUNT(*) AS n FROM room_attachments WHERE resource = 'general'").n, 1);
+ assert.strictEqual((await h.db.get("SELECT COUNT(*) AS n FROM room_attachments WHERE resource = 'general'")).n, 1);
assert.strictEqual((await api('POST', '/tea-talk/attachments', mem, { service: 'community', resource: 'help' })).status, 403, 'members do not attach');
assert.strictEqual((await api('POST', '/tea-talk/attachments', mem, { service: 'community', resource: 'help' })).status, 403, 'members do not attach');
assert.strictEqual((await api('POST', '/back-stage/attachments', out, { service: 'community', resource: 'help' })).status, 404, 'a private room stays missing');
assert.strictEqual((await api('POST', '/back-stage/attachments', out, { service: 'community', resource: 'help' })).status, 404, 'a private room stays missing');
assert.strictEqual((await api('POST', '/tea-talk/attachments', bot, { service: 'community', resource: 'help' })).status, 403, 'API tokens do not attach');
assert.strictEqual((await api('POST', '/tea-talk/attachments', bot, { service: 'community', resource: 'help' })).status, 403, 'API tokens do not attach');
@@ -18,7 +18,7 @@ t('boot', async () => {
@@ -18,7 +18,7 @@ t('boot', async () => {
bob = h.addUser('bob', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
cat = h.addUser('cat', { subject: ids.newId('user') });
cat = h.addUser('cat', { subject: ids.newId('user') });
mod = h.addUser('staffmod', { subject: ids.newId('user'), role: 'global_mod' });
mod = h.addUser('staffmod', { subject: ids.newId('user'), role: 'global_mod' });
- for (const u of [ann, bob, cat, mod]) h.ctx.upsertUser(h.live.users.get(u.id));
+ for (const u of [ann, bob, cat, mod]) await h.ctx.upsertUser(h.live.users.get(u.id));
});
});
t('create, list, join, post, history and unread', async () => {
t('create, list, join, post, history and unread', async () => {
@@ -87,7 +87,7 @@ t('moderation: delete and block, owner-only mods, logged; live delivery only to
@@ -87,7 +87,7 @@ t('moderation: delete and block, owner-only mods, logged; live delivery only to
assert.strictEqual(r.body.role, 'blocked');
assert.strictEqual(r.body.role, 'blocked');
await bobWs.next((m) => m.type === 'room_left');
await bobWs.next((m) => m.type === 'room_left');
assert.strictEqual((await api('POST', '/night-owls/messages', bob, { message: 'let me back' })).status, 404, 'blocked: the room is gone for them');
assert.strictEqual((await api('POST', '/night-owls/messages', bob, { message: 'let me back' })).status, 404, 'blocked: the room is gone for them');
- const logged = h.db.all("SELECT action_type FROM moderation_actions WHERE scope_type = 'room' ORDER BY id");
+ const logged = await h.db.all("SELECT action_type FROM moderation_actions WHERE scope_type = 'room' ORDER BY id");
assert.deepStrictEqual(logged.map((x) => x.action_type), ['delete_message', 'room_block']);
assert.deepStrictEqual(logged.map((x) => x.action_type), ['delete_message', 'room_block']);
for (const ws of [annWs, bobWs, outsider]) ws.close();
for (const ws of [annWs, bobWs, outsider]) ws.close();
});
});
@@ -111,15 +111,15 @@ t('slow mode and site bans', async () => {
@@ -111,15 +111,15 @@ t('slow mode and site bans', async () => {
t('realtime plane (WS-I task 9): public rooms announce messages and redactions, private rooms never do', async () => {
t('realtime plane (WS-I task 9): public rooms announce messages and redactions, private rooms never do', async () => {
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
const dan = h.addUser('dan', { subject: ids.newId('user') });
const dan = h.addUser('dan', { subject: ids.newId('user') });
- h.ctx.upsertUser(h.live.users.get(dan.id));
+ await h.ctx.upsertUser(h.live.users.get(dan.id));
const db = require('../server/db/database');
const db = require('../server/db/database');
- const since = (db.get('SELECT MAX(rowid) AS n FROM events_outbox') || {}).n || 0;
- const newEvents = () => db.all('SELECT event FROM events_outbox WHERE rowid > ? ORDER BY rowid', [since]).map((r) => JSON.parse(r.event)).filter((e) => /^chat\.room\./.test(e.event_type));
+ const since = (await db.get('SELECT MAX(seq) AS n FROM events_outbox') || {}).n || 0;
+ const newEvents = async () => (await db.all('SELECT event FROM events_outbox WHERE seq > ? ORDER BY seq', [since])).map((r) => JSON.parse(r.event)).filter((e) => /^chat\.room\./.test(e.event_type));
let r = await api('POST', '/', dan, { name: 'Open Plaza' });
let r = await api('POST', '/', dan, { name: 'Open Plaza' });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
r = await api('POST', '/open-plaza/messages', dan, { message: 'hello plaza' });
r = await api('POST', '/open-plaza/messages', dan, { message: 'hello plaza' });
const msgId = r.body.message.id;
const msgId = r.body.message.id;
- let ev = newEvents();
+ let ev = await newEvents();
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
assert.deepStrictEqual([ev[0].event_type, ev[0].visibility, ev[0].subject], ['chat.room.message.created', 'public', { type: 'chat_room_message', id: String(msgId) }]);
assert.deepStrictEqual([ev[0].event_type, ev[0].visibility, ev[0].subject], ['chat.room.message.created', 'public', { type: 'chat_room_message', id: String(msgId) }]);
assert.strictEqual(ev[0].payload.room.slug, 'open-plaza'); assert.strictEqual(ev[0].payload.text, 'hello plaza');
assert.strictEqual(ev[0].payload.room.slug, 'open-plaza'); assert.strictEqual(ev[0].payload.text, 'hello plaza');
@@ -130,13 +130,13 @@ t('realtime plane (WS-I task 9): public rooms announce messages and redactions,
@@ -130,13 +130,13 @@ t('realtime plane (WS-I task 9): public rooms announce messages and redactions,
r = await api('POST', '/', dan, { name: 'Back Room', visibility: 'private' });
r = await api('POST', '/', dan, { name: 'Back Room', visibility: 'private' });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
await api('POST', '/back-room/messages', dan, { message: 'just us' });
await api('POST', '/back-room/messages', dan, { message: 'just us' });
- assert.strictEqual(newEvents().length, 1, 'no event for a private room');
+ assert.strictEqual((await newEvents()).length, 1, 'no event for a private room');
// Deleting a public message redacts it; turning the room private redacts the rest.
// Deleting a public message redacts it; turning the room private redacts the rest.
await api('POST', '/open-plaza/messages', dan, { message: 'second' });
await api('POST', '/open-plaza/messages', dan, { message: 'second' });
await api('POST', '/open-plaza/messages', dan, { message: 'third' });
await api('POST', '/open-plaza/messages', dan, { message: 'third' });
assert.strictEqual((await api('DELETE', `/open-plaza/messages/${msgId}`, dan)).status, 200);
assert.strictEqual((await api('DELETE', `/open-plaza/messages/${msgId}`, dan)).status, 200);
- ev = newEvents();
+ ev = await newEvents();
const del = ev.filter((e) => e.event_type === 'chat.room.message.deleted');
const del = ev.filter((e) => e.event_type === 'chat.room.message.deleted');
assert.strictEqual(del.length, 1);
assert.strictEqual(del.length, 1);
assert.deepStrictEqual(del[0].payload.message_ids, [msgId]);
assert.deepStrictEqual(del[0].payload.message_ids, [msgId]);
@@ -144,10 +144,10 @@ t('realtime plane (WS-I task 9): public rooms announce messages and redactions,
@@ -144,10 +144,10 @@ t('realtime plane (WS-I task 9): public rooms announce messages and redactions,
assert.ok(validate('chat.room.message.deleted@1', del[0].payload).valid);
assert.ok(validate('chat.room.message.deleted@1', del[0].payload).valid);
r = await api('PATCH', '/open-plaza', dan, { visibility: 'private' });
r = await api('PATCH', '/open-plaza', dan, { visibility: 'private' });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- const last = newEvents().filter((e) => e.event_type === 'chat.room.message.deleted').pop();
+ const last = (await newEvents()).filter((e) => e.event_type === 'chat.room.message.deleted').pop();
assert.strictEqual(last.payload.message_ids.length, 2, 'the two remaining public messages are redacted');
assert.strictEqual(last.payload.message_ids.length, 2, 'the two remaining public messages are redacted');
await api('POST', '/open-plaza/messages', dan, { message: 'now private' });
await api('POST', '/open-plaza/messages', dan, { message: 'now private' });
- assert.strictEqual(newEvents().filter((e) => e.event_type === 'chat.room.message.created').length, 3, 'nothing new once private');
+ assert.strictEqual((await newEvents()).filter((e) => e.event_type === 'chat.room.message.created').length, 3, 'nothing new once private');
});
});
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });
@@ -8,8 +8,19 @@
@@ -8,8 +8,19 @@
* npm test -- ws import # only files whose name contains one of the words
* npm test -- ws import # only files whose name contains one of the words
* npm test -- --strict # a skipped test fails the run too
* npm test -- --strict # a skipped test fails the run too
*
*
+ * npm run test:pg # the same on PostgreSQL through PgBouncer, plus Valkey (OV_TEST_PG_URL,
+ * # OV_TEST_PG_DIRECT_URL, OV_TEST_VALKEY_URL: openvibe-sdk scripts/test-services.sh up)
+ *
+ * Every test process gets one migrated database from test/helpers/pg-preload.mjs (node --import): an
+ * in-memory PGlite, or with OV_TEST_STORE=pg a schema and roles of its own on the PostgreSQL server.
+ *
* A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with
* A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with
* ○ and not counted as passed (openvibe-shared/test-runner).
* ○ and not counted as passed (openvibe-shared/test-runner).
*/
*/
'use strict';
'use strict';
-require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 32, parallel: 1 });
+const path = require('path');
+const { pathToFileURL } = require('url');
+require('openvibe-shared/test-runner').main({
+ dir: __dirname, timeoutMs: 60000, pad: 32, parallel: 1,
+ nodeArgs: ['--import', pathToFileURL(path.join(__dirname, 'helpers', 'pg-preload.mjs')).href],
+});
@@ -24,8 +24,8 @@ const refused = (r, what) => assert.ok([401, 403, 404].includes(r.status), `${wh
@@ -24,8 +24,8 @@ const refused = (r, what) => assert.ok([401, 403, 404].includes(r.status), `${wh
t('boot and seed', async () => {
t('boot and seed', async () => {
h = await boot({ env: { CHAT_CALLS: '1', CHAT_WEB_URL: 'https://openvibe.chat' } });
h = await boot({ env: { CHAT_CALLS: '1', CHAT_WEB_URL: 'https://openvibe.chat' } });
- const mk = (name, opts = {}) => { const u = h.addUser(name, { subject: ids.newId('user'), ...opts }); h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); return u; };
- ann = mk('ann', { role: 'streamer' }); bob = mk('bob', { role: 'streamer' }); cat = mk('cat');
+ const mk = async (name, opts = {}) => { const u = h.addUser(name, { subject: ids.newId('user'), ...opts }); await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); return u; };
+ ann = await mk('ann', { role: 'streamer' }); bob = await mk('bob', { role: 'streamer' }); cat = await mk('cat');
annStream = h.addStream(ann.id, h.addChannel(ann.id), { title: 'Ann live', is_live: 1 });
annStream = h.addStream(ann.id, h.addChannel(ann.id), { title: 'Ann live', is_live: 1 });
bobStream = h.addStream(bob.id, h.addChannel(bob.id), { title: 'Bob live', is_live: 1 });
bobStream = h.addStream(bob.id, h.addChannel(bob.id), { title: 'Bob live', is_live: 1 });
@@ -43,10 +43,10 @@ t('boot and seed', async () => {
@@ -43,10 +43,10 @@ t('boot and seed', async () => {
r = await api('POST', `/api/dm/conversations/${s.conv}/messages`, ann, { message: 'ann in the trio' });
r = await api('POST', `/api/dm/conversations/${s.conv}/messages`, ann, { message: 'ann in the trio' });
s.dmMsg = (r.body.message && r.body.message.id) || r.body.id;
s.dmMsg = (r.body.message && r.body.message.id) || r.body.id;
- s.sound = Number(h.db.createChannelSound({ channel_owner_id: ann.id, command: 'annhorn', url: '/api/sounds/file/none.mp3', created_by: ann.id, created_by_name: 'ann' }).lastInsertRowid);
+ s.sound = Number((await h.db.createChannelSound({ channel_owner_id: ann.id, command: 'annhorn', url: '/api/sounds/file/none.mp3', created_by: ann.id, created_by_name: 'ann' })).lastInsertRowid);
const audioQueue = require('../server/chat/audio-queue');
const audioQueue = require('../server/chat/audio-queue');
audioQueue.stop(); // nothing plays here: Ann's request stays queued, so any change to it is Bob's doing
audioQueue.stop(); // nothing plays here: Ann's request stays queued, so any change to it is Bob's doing
- s.annReq = audioQueue.enqueue({ kind: 'tts', streamId: annStream, requestedBy: cat.id, label: 'hello', payload: { text: 'hello' } });
+ s.annReq = await audioQueue.enqueue({ kind: 'tts', streamId: annStream, requestedBy: cat.id, label: 'hello', payload: { text: 'hello' } });
s.annReqId = s.annReq && (s.annReq.id || (s.annReq.request && s.annReq.request.id));
s.annReqId = s.annReq && (s.annReq.id || (s.annReq.request && s.annReq.request.id));
assert.ok(s.annReqId, JSON.stringify(s.annReq));
assert.ok(s.annReqId, JSON.stringify(s.annReq));
});
});
@@ -57,15 +57,15 @@ const same = (a, b) => {
@@ -57,15 +57,15 @@ const same = (a, b) => {
const i = x.findIndex((v, k) => JSON.stringify(v) !== JSON.stringify(y[k]));
const i = x.findIndex((v, k) => JSON.stringify(v) !== JSON.stringify(y[k]));
assert.fail(`table #${i} changed:\n before ${JSON.stringify(x[i]).slice(0, 600)}\n after ${JSON.stringify(y[i]).slice(0, 600)}`);
assert.fail(`table #${i} changed:\n before ${JSON.stringify(x[i]).slice(0, 600)}\n after ${JSON.stringify(y[i]).slice(0, 600)}`);
};
};
-const snapshot = () => JSON.stringify([
- h.db.all('SELECT * FROM rooms ORDER BY id'), h.db.all('SELECT room_id, user_id, role FROM room_members ORDER BY room_id, user_id'),
- h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), h.db.all('SELECT * FROM room_attachments ORDER BY 1'),
- h.db.all('SELECT conversation_id, user_id FROM dm_participants ORDER BY conversation_id, user_id'), h.db.all('SELECT id, message FROM dm_messages ORDER BY id'),
- h.db.all('SELECT id, channel_owner_id, command FROM channel_sounds ORDER BY id'), h.db.all('SELECT id, state FROM audio_requests ORDER BY id'),
+const snapshot = async () => JSON.stringify([
+ await h.db.all('SELECT * FROM rooms ORDER BY id'), await h.db.all('SELECT room_id, user_id, role FROM room_members ORDER BY room_id, user_id'),
+ await h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), await h.db.all('SELECT * FROM room_attachments ORDER BY 1'),
+ await h.db.all('SELECT conversation_id, user_id FROM dm_participants ORDER BY conversation_id, user_id'), await h.db.all('SELECT id, message FROM dm_messages ORDER BY id'),
+ await h.db.all('SELECT id, channel_owner_id, command FROM channel_sounds ORDER BY id'), await h.db.all('SELECT id, state FROM audio_requests ORDER BY id'),
]);
]);
t('rooms: Bob, a member of Ann\'s room, cannot change it, delete her or Cat\'s messages, or hand out roles', async () => {
t('rooms: Bob, a member of Ann\'s room, cannot change it, delete her or Cat\'s messages, or hand out roles', async () => {
- const before = snapshot();
+ const before = await snapshot();
refused(await rooms('PATCH', `/${s.room}`, bob, { name: 'Bob Room', visibility: 'private', slow_seconds: 600 }), 'PATCH room');
refused(await rooms('PATCH', `/${s.room}`, bob, { name: 'Bob Room', visibility: 'private', slow_seconds: 600 }), 'PATCH room');
refused(await rooms('DELETE', `/${s.room}/messages/${s.annMsg}`, bob), 'delete Ann\'s message');
refused(await rooms('DELETE', `/${s.room}/messages/${s.annMsg}`, bob), 'delete Ann\'s message');
refused(await rooms('DELETE', `/${s.room}/messages/${s.catMsg}`, bob), 'delete Cat\'s message');
refused(await rooms('DELETE', `/${s.room}/messages/${s.catMsg}`, bob), 'delete Cat\'s message');
@@ -74,27 +74,27 @@ t('rooms: Bob, a member of Ann\'s room, cannot change it, delete her or Cat\'s m
@@ -74,27 +74,27 @@ t('rooms: Bob, a member of Ann\'s room, cannot change it, delete her or Cat\'s m
}
}
refused(await rooms('POST', `/${s.room}/attachments`, bob, { service: 'community', resource: 'bobs-space' }), 'attach');
refused(await rooms('POST', `/${s.room}/attachments`, bob, { service: 'community', resource: 'bobs-space' }), 'attach');
refused(await rooms('DELETE', `/${s.room}/attachments/community/anything`, bob), 'detach');
refused(await rooms('DELETE', `/${s.room}/attachments/community/anything`, bob), 'detach');
- same(before, snapshot());
+ same(before, await snapshot());
});
});
t('group DM: Bob cannot delete Ann\'s message or remove Cat (only the creator removes others)', async () => {
t('group DM: Bob cannot delete Ann\'s message or remove Cat (only the creator removes others)', async () => {
- const before = snapshot();
+ const before = await snapshot();
refused(await api('DELETE', `/api/dm/conversations/${s.conv}/messages/${s.dmMsg}`, bob), 'delete Ann\'s DM');
refused(await api('DELETE', `/api/dm/conversations/${s.conv}/messages/${s.dmMsg}`, bob), 'delete Ann\'s DM');
refused(await api('DELETE', `/api/dm/conversations/${s.conv}/participants/${cat.id}`, bob), 'remove Cat');
refused(await api('DELETE', `/api/dm/conversations/${s.conv}/participants/${cat.id}`, bob), 'remove Cat');
refused(await api('DELETE', `/api/dm/conversations/${s.conv}/participants/${ann.id}`, bob), 'remove Ann');
refused(await api('DELETE', `/api/dm/conversations/${s.conv}/participants/${ann.id}`, bob), 'remove Ann');
- same(before, snapshot());
+ same(before, await snapshot());
});
});
t('sounds: Bob cannot delete Ann\'s channel sound or rename her sound command', async () => {
t('sounds: Bob cannot delete Ann\'s channel sound or rename her sound command', async () => {
- const before = snapshot();
+ const before = await snapshot();
refused(await api('DELETE', `/api/sounds/${s.sound}`, bob), 'delete sound');
refused(await api('DELETE', `/api/sounds/${s.sound}`, bob), 'delete sound');
refused(await api('PATCH', '/api/sounds/command', bob, { channel_id: ann.id, command: 'annhorn', new_command: 'pwned', emote_code: 'x' }), 'rename command (channel)');
refused(await api('PATCH', '/api/sounds/command', bob, { channel_id: ann.id, command: 'annhorn', new_command: 'pwned', emote_code: 'x' }), 'rename command (channel)');
refused(await api('PATCH', '/api/sounds/command', bob, { stream_id: annStream, command: 'annhorn', new_command: 'pwned' }), 'rename command (stream)');
refused(await api('PATCH', '/api/sounds/command', bob, { stream_id: annStream, command: 'annhorn', new_command: 'pwned' }), 'rename command (stream)');
- same(before, snapshot());
+ same(before, await snapshot());
});
});
t('TTS/sound queue: Bob cannot read, skip, clear or report in Ann\'s room, nor reach her request through his own', async () => {
t('TTS/sound queue: Bob cannot read, skip, clear or report in Ann\'s room, nor reach her request through his own', async () => {
- const before = snapshot();
+ const before = await snapshot();
refused(await api('GET', `/api/tts/queue?stream_id=${annStream}`, bob), 'list Ann\'s queue');
refused(await api('GET', `/api/tts/queue?stream_id=${annStream}`, bob), 'list Ann\'s queue');
refused(await api('GET', `/api/tts/queue?channel_user_id=${ann.id}`, bob), 'list Ann\'s channel queue');
refused(await api('GET', `/api/tts/queue?channel_user_id=${ann.id}`, bob), 'list Ann\'s channel queue');
refused(await api('POST', '/api/tts/queue/skip', bob, { stream_id: annStream, id: s.annReqId }), 'skip in Ann\'s room');
refused(await api('POST', '/api/tts/queue/skip', bob, { stream_id: annStream, id: s.annReqId }), 'skip in Ann\'s room');
@@ -104,7 +104,7 @@ t('TTS/sound queue: Bob cannot read, skip, clear or report in Ann\'s room, nor r
@@ -104,7 +104,7 @@ t('TTS/sound queue: Bob cannot read, skip, clear or report in Ann\'s room, nor r
assert.ok([404, 409].includes(through.status), `Ann's request through Bob's room: ${through.status} ${through.text}`);
assert.ok([404, 409].includes(through.status), `Ann's request through Bob's room: ${through.status} ${through.text}`);
const report = await api('POST', `/api/tts/queue/${s.annReqId}/report`, bob, { stream_id: bobStream, state: 'failed', error: 'x' });
const report = await api('POST', `/api/tts/queue/${s.annReqId}/report`, bob, { stream_id: bobStream, state: 'failed', error: 'x' });
assert.ok([400, 403, 404, 409].includes(report.status), `report Ann's request from Bob's room: ${report.status} ${report.text}`);
assert.ok([400, 403, 404, 409].includes(report.status), `report Ann's request from Bob's room: ${report.status} ${report.text}`);
- same(before, snapshot());
+ same(before, await snapshot());
});
});
t('calls: Bob cannot change Ann\'s stream call settings or delete her voice channel', async () => {
t('calls: Bob cannot change Ann\'s stream call settings or delete her voice channel', async () => {
@@ -119,9 +119,9 @@ t('calls: Bob cannot change Ann\'s stream call settings or delete her voice chan
@@ -119,9 +119,9 @@ t('calls: Bob cannot change Ann\'s stream call settings or delete her voice chan
});
});
t('moderation: Bob cannot purge Ann\'s stream chat', async () => {
t('moderation: Bob cannot purge Ann\'s stream chat', async () => {
- h.db.saveChatMessage({ stream_id: annStream, channel_user_id: ann.id, user_id: cat.id, username: 'cat', message: 'cat chats on ann' });
- const count = () => h.db.all('SELECT count(*) AS n FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0', [annStream])[0].n;
- const before = count();
+ await h.db.saveChatMessage({ stream_id: annStream, channel_user_id: ann.id, user_id: cat.id, username: 'cat', message: 'cat chats on ann' });
+ const count = async () => (await h.db.all('SELECT count(*) AS n FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0', [annStream]))[0].n;
+ const before = await count();
assert.ok(before >= 1);
assert.ok(before >= 1);
const range = { from: '2000-01-01T00:00:00Z', to: '2100-01-01T00:00:00Z' };
const range = { from: '2000-01-01T00:00:00Z', to: '2100-01-01T00:00:00Z' };
for (const key of ['streamId', 'stream_id']) {
for (const key of ['streamId', 'stream_id']) {
@@ -130,7 +130,7 @@ t('moderation: Bob cannot purge Ann\'s stream chat', async () => {
@@ -130,7 +130,7 @@ t('moderation: Bob cannot purge Ann\'s stream chat', async () => {
const p = await api('POST', '/api/chat/admin/purge/preview', bob, { [key]: annStream, ...range });
const p = await api('POST', '/api/chat/admin/purge/preview', bob, { [key]: annStream, ...range });
if (key === 'streamId') refused(p, 'preview Ann\'s stream');
if (key === 'streamId') refused(p, 'preview Ann\'s stream');
}
}
- assert.strictEqual(count(), before, 'Ann\'s chat is intact');
+ assert.strictEqual(await count(), before, 'Ann\'s chat is intact');
});
});
t('preferences: a body naming Ann writes Bob\'s own record only', async () => {
t('preferences: a body naming Ann writes Bob\'s own record only', async () => {
@@ -38,7 +38,7 @@ t('boot and seed: a private room, a private call room, a DM', async () => {
@@ -38,7 +38,7 @@ t('boot and seed: a private room, a private call room, a DM', async () => {
cat = h.addUser('cat', { subject: ids.newId('user') });
cat = h.addUser('cat', { subject: ids.newId('user') });
dan = h.addUser('dan', { subject: ids.newId('user') });
dan = h.addUser('dan', { subject: ids.newId('user') });
mod = h.addUser('staffmod', { subject: ids.newId('user'), role: 'global_mod' });
mod = h.addUser('staffmod', { subject: ids.newId('user'), role: 'global_mod' });
- for (const u of [ann, bob, cat, dan, mod]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
+ for (const u of [ann, bob, cat, dan, mod]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
let r = await rooms('POST', '/', ann, { name: SECRET.roomName, visibility: 'private', topic: SECRET.topic });
let r = await rooms('POST', '/', ann, { name: SECRET.roomName, visibility: 'private', topic: SECRET.topic });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
@@ -122,12 +122,12 @@ t('WebSocket: a stranger cannot join the private room, and hears nothing posted
@@ -122,12 +122,12 @@ t('WebSocket: a stranger cannot join the private room, and hears nothing posted
t('events outbox: nothing about a private room or a DM leaves Chat', async () => {
t('events outbox: nothing about a private room or a DM leaves Chat', async () => {
// (The staff member's own log searches above are audited with the words they typed: their query, not the room's content.)
// (The staff member's own log searches above are audited with the words they typed: their query, not the room's content.)
- const rows = JSON.stringify(h.db.all('SELECT * FROM events_outbox').filter((r) => !/"action_type":"chat_log_search"/.test(r.event)));
+ const rows = JSON.stringify((await h.db.all('SELECT * FROM events_outbox')).filter((r) => !/"action_type":"chat_log_search"/.test(r.event)));
for (const w of ['live-private-words', 'live-dm-words', SECRET.roomWords, SECRET.callWords, SECRET.dmWords, SECRET.roomName, SECRET.topic]) assert.ok(!rows.includes(w), `outbox carries "${w}"`);
for (const w of ['live-private-words', 'live-dm-words', SECRET.roomWords, SECRET.callWords, SECRET.dmWords, SECRET.roomName, SECRET.topic]) assert.ok(!rows.includes(w), `outbox carries "${w}"`);
});
});
t('ids swapped into the stranger\'s own room reach nothing of the private room', async () => {
t('ids swapped into the stranger\'s own room reach nothing of the private room', async () => {
- const before = h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id');
+ const before = await h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id');
let r = await rooms('DELETE', `/${seeded.open}/messages/${seeded.msg}`, cat);
let r = await rooms('DELETE', `/${seeded.open}/messages/${seeded.msg}`, cat);
assert.strictEqual(r.status, 404, r.text);
assert.strictEqual(r.status, 404, r.text);
r = await rooms('DELETE', `/${seeded.open}/messages/${seeded.callMsg}`, cat);
r = await rooms('DELETE', `/${seeded.open}/messages/${seeded.callMsg}`, cat);
@@ -137,14 +137,14 @@ t('ids swapped into the stranger\'s own room reach nothing of the private room',
@@ -137,14 +137,14 @@ t('ids swapped into the stranger\'s own room reach nothing of the private room',
assert.ok(!r.text.includes(SECRET.roomWords) && !r.text.includes(SECRET.callWords));
assert.ok(!r.text.includes(SECRET.roomWords) && !r.text.includes(SECRET.callWords));
r = await dms('DELETE', `/conversations/${seeded.conv}/messages/${seeded.dmMsg}`, cat);
r = await dms('DELETE', `/conversations/${seeded.conv}/messages/${seeded.dmMsg}`, cat);
assert.ok([403, 404].includes(r.status), r.text);
assert.ok([403, 404].includes(r.status), r.text);
- assert.deepStrictEqual(h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), before);
+ assert.deepStrictEqual(await h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), before);
assert.ok((await dms('GET', `/conversations/${seeded.conv}/messages`, ann)).text.includes(SECRET.dmWords), 'the DM is intact');
assert.ok((await dms('GET', `/conversations/${seeded.conv}/messages`, ann)).text.includes(SECRET.dmWords), 'the DM is intact');
});
});
t('a stranger\'s every write to the private room or the DM is refused and changes nothing', async () => {
t('a stranger\'s every write to the private room or the DM is refused and changes nothing', async () => {
- const snap = () => JSON.stringify([h.db.all('SELECT * FROM rooms ORDER BY id'), h.db.all('SELECT room_id, user_id, role FROM room_members ORDER BY room_id, user_id'),
- h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), h.db.all('SELECT * FROM dm_participants ORDER BY conversation_id, user_id')]);
- const before = snap();
+ const snap = async () => JSON.stringify([await h.db.all('SELECT * FROM rooms ORDER BY id'), await h.db.all('SELECT room_id, user_id, role FROM room_members ORDER BY room_id, user_id'),
+ await h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), await h.db.all('SELECT * FROM dm_participants ORDER BY conversation_id, user_id')]);
+ const before = await snap();
for (const [m, p, body] of [
for (const [m, p, body] of [
['PATCH', `/${seeded.slug}`, { name: 'pwned', visibility: 'public' }], ['POST', `/${seeded.slug}/join`], ['POST', `/${seeded.slug}/messages`, { message: 'x' }],
['PATCH', `/${seeded.slug}`, { name: 'pwned', visibility: 'public' }], ['POST', `/${seeded.slug}/join`], ['POST', `/${seeded.slug}/messages`, { message: 'x' }],
['DELETE', `/${seeded.slug}/messages/${seeded.msg}`], ['POST', `/${seeded.slug}/members`, { username: 'cat', role: 'mod' }], ['POST', `/${seeded.slug}/read`],
['DELETE', `/${seeded.slug}/messages/${seeded.msg}`], ['POST', `/${seeded.slug}/members`, { username: 'cat', role: 'mod' }], ['POST', `/${seeded.slug}/read`],
@@ -167,7 +167,7 @@ t('a stranger\'s every write to the private room or the DM is refused and change
@@ -167,7 +167,7 @@ t('a stranger\'s every write to the private room or the DM is refused and change
assert.ok(!r.text.includes(SECRET.dmWords), `${m} ${p} carries the DM`);
assert.ok(!r.text.includes(SECRET.dmWords), `${m} ${p} carries the DM`);
}
}
}
}
- assert.strictEqual(snap(), before);
+ assert.strictEqual(await snap(), before);
});
});
t.run(() => h && h.close());
t.run(() => h && h.close());
@@ -40,10 +40,10 @@ t('boot with a sentinel in every secret', async () => {
@@ -40,10 +40,10 @@ t('boot with a sentinel in every secret', async () => {
h = await boot({ env: { ...ENV, CHAT_WEB_URL: 'https://openvibe.chat' } });
h = await boot({ env: { ...ENV, CHAT_WEB_URL: 'https://openvibe.chat' } });
Object.assign(h.live.settings, SETTINGS, { tts_enabled: true, tts_provider: 'google' });
Object.assign(h.live.settings, SETTINGS, { tts_enabled: true, tts_provider: 'google' });
await h.ctx.ensureSettings(true);
await h.ctx.ensureSettings(true);
- const mk = (name, opts = {}) => { const u = h.addUser(name, { subject: ids.newId('user'), ...opts }); h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); return u; };
+ const mk = async (name, opts = {}) => { const u = h.addUser(name, { subject: ids.newId('user'), ...opts }); await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); return u; };
people = {
people = {
- anonymous: null, viewer: mk('viewer'), streamer: mk('streamer', { role: 'streamer' }), 'global mod': mk('staffmod', { role: 'global_mod' }),
- admin: mk('admin', { role: 'admin' }), 'site owner': mk('owner', { role: 'admin', is_owner: 1 }),
+ anonymous: null, viewer: await mk('viewer'), streamer: await mk('streamer', { role: 'streamer' }), 'global mod': await mk('staffmod', { role: 'global_mod' }),
+ admin: await mk('admin', { role: 'admin' }), 'site owner': await mk('owner', { role: 'admin', is_owner: 1 }),
};
};
people.stream = h.addStream(people.streamer.id, h.addChannel(people.streamer.id), { title: 'Live now', is_live: 1 });
people.stream = h.addStream(people.streamer.id, h.addChannel(people.streamer.id), { title: 'Live now', is_live: 1 });
});
});
@@ -113,7 +113,7 @@ t('internal routes with a wrong, missing or other-audience service token, and th
@@ -113,7 +113,7 @@ t('internal routes with a wrong, missing or other-audience service token, and th
});
});
t('the events outbox and the log lines carry no secret', async () => {
t('the events outbox and the log lines carry no secret', async () => {
- const rows = JSON.stringify(h.db.all('SELECT * FROM events_outbox'));
+ const rows = JSON.stringify(await h.db.all('SELECT * FROM events_outbox'));
for (const [k, v] of Object.entries({ ...ENV, ...SETTINGS })) {
for (const [k, v] of Object.entries({ ...ENV, ...SETTINGS })) {
assert.ok(!rows.includes(v), `outbox carries ${k}`);
assert.ok(!rows.includes(v), `outbox carries ${k}`);
const line = logs.find((l) => l.includes(v));
const line = logs.find((l) => l.includes(v));
@@ -87,7 +87,7 @@ t('/me, /clear and ban notices from global chat or a channel room stay in that r
@@ -87,7 +87,7 @@ t('/me, /clear and ban notices from global chat or a channel room stay in that r
});
});
t('/me and /tts obey followers-only and the slur filter', async () => {
t('/me and /tts obey followers-only and the slur filter', async () => {
- h.setChannelSettings(chanX, { followers_only: 1, slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });
+ await h.setChannelSettings(chanX, { followers_only: 1, slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });
const viewer = await joined({ ip: '198.51.100.20', token: streamer.token, stream: streamX }, { streamId: streamX });
const viewer = await joined({ ip: '198.51.100.20', token: streamer.token, stream: streamX }, { streamId: streamX });
const bobWs = await joined({ ip: '198.51.100.21', token: bob.token, stream: streamX }, { streamId: streamX });
const bobWs = await joined({ ip: '198.51.100.21', token: bob.token, stream: streamX }, { streamId: streamX });
bobWs.sendJson({ type: 'chat', message: 'plain line' });
bobWs.sendJson({ type: 'chat', message: 'plain line' });
@@ -99,7 +99,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
@@ -99,7 +99,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
assert.ok(await viewer.none((m) => /sneaks past/.test(m.message || ''), 500), '/me or /tts bypassed followers-only');
assert.ok(await viewer.none((m) => /sneaks past/.test(m.message || ''), 500), '/me or /tts bypassed followers-only');
h.live.follows.set(bob.id, [streamer.id]);
h.live.follows.set(bob.id, [streamer.id]);
- h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });
+ await h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_terms: 'bannedword' });
const bob2 = await joined({ ip: '198.51.100.22', token: bob.token, stream: streamX }, { streamId: streamX });
const bob2 = await joined({ ip: '198.51.100.22', token: bob.token, stream: streamX }, { streamId: streamX });
bob2.sendJson({ type: 'chat', message: '/me says bannedword' });
bob2.sendJson({ type: 'chat', message: '/me says bannedword' });
await bob2.next((m) => m.type === 'slur-blocked');
await bob2.next((m) => m.type === 'slur-blocked');
@@ -107,7 +107,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
@@ -107,7 +107,7 @@ t('/me and /tts obey followers-only and the slur filter', async () => {
bob2.sendJson({ type: 'chat', message: '/tts bannedword' });
bob2.sendJson({ type: 'chat', message: '/tts bannedword' });
await bob2.next((m) => m.type === 'slur-blocked');
await bob2.next((m) => m.type === 'slur-blocked');
assert.ok(await viewer.none((m) => /bannedword/.test(m.message || ''), 300), 'slur filter bypassed by /me or /tts');
assert.ok(await viewer.none((m) => /bannedword/.test(m.message || ''), 300), 'slur filter bypassed by /me or /tts');
- h.setChannelSettings(chanX, null);
+ await h.setChannelSettings(chanX, null);
for (const w of [viewer, bobWs, bob2]) w.close();
for (const w of [viewer, bobWs, bob2]) w.close();
});
});
@@ -124,21 +124,21 @@ t('joining the channel room without the stream id does not skip slow mode or IP
@@ -124,21 +124,21 @@ t('joining the channel room without the stream id does not skip slow mode or IP
assert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');
assert.ok(await own.none((m) => m.message === 'second within slow mode'), 'slow mode bypassed from the channel room');
// (/slow 30 is the channel's saved setting now; the policy set below replaces it)
// (/slow 30 is the channel's saved setting now; the policy set below replaces it)
- h.setChannelSettings(chanX, { ip_approval_mode: 1 });
+ await h.setChannelSettings(chanX, { ip_approval_mode: 1 });
const fresh = await joined({ ip: '198.51.100.32' }, { channelUserId: streamer.id });
const fresh = await joined({ ip: '198.51.100.32' }, { channelUserId: streamer.id });
fresh.sendJson({ type: 'chat', message: 'unapproved hello' });
fresh.sendJson({ type: 'chat', message: 'unapproved hello' });
await fresh.next((m) => m.type === 'system' && /IP approval mode/.test(m.message));
await fresh.next((m) => m.type === 'system' && /IP approval mode/.test(m.message));
assert.ok(await own.none((m) => m.message === 'unapproved hello'), 'IP approval bypassed from the channel room');
assert.ok(await own.none((m) => m.message === 'unapproved hello'), 'IP approval bypassed from the channel room');
- assert.ok(h.db.get("SELECT 1 FROM pending_ip_messages WHERE message = 'unapproved hello'"), 'held for review');
+ assert.ok(await h.db.get("SELECT 1 FROM pending_ip_messages WHERE message = 'unapproved hello'"), 'held for review');
await h.sleep(1100);
await h.sleep(1100);
fresh.sendJson({ type: 'chat', message: '/me unapproved action' });
fresh.sendJson({ type: 'chat', message: '/me unapproved action' });
assert.ok(await own.none((m) => /unapproved action/.test(m.message || ''), 400), '/me bypassed IP approval');
assert.ok(await own.none((m) => /unapproved action/.test(m.message || ''), 400), '/me bypassed IP approval');
- h.setChannelSettings(chanX, null);
+ await h.setChannelSettings(chanX, null);
for (const w of [own, sneaky, fresh]) w.close();
for (const w of [own, sneaky, fresh]) w.close();
});
});
t('a catastrophic custom slur regex cannot freeze the chat server', async () => {
t('a catastrophic custom slur regex cannot freeze the chat server', async () => {
- h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_use_builtin: 0, slur_filter_regexes: '^(a|a)*$' });
+ await h.setChannelSettings(chanX, { slur_filter_enabled: 1, slur_filter_use_builtin: 0, slur_filter_regexes: '^(a|a)*$' });
const viewer = await joined({ ip: '198.51.100.40', token: streamer.token, stream: streamX }, { streamId: streamX });
const viewer = await joined({ ip: '198.51.100.40', token: streamer.token, stream: streamX }, { streamId: streamX });
const bobWs = await joined({ ip: '198.51.100.41', token: bob.token, stream: streamX }, { streamId: streamX });
const bobWs = await joined({ ip: '198.51.100.41', token: bob.token, stream: streamX }, { streamId: streamX });
const line = 'a'.repeat(30) + 'b';
const line = 'a'.repeat(30) + 'b';
@@ -146,7 +146,7 @@ t('a catastrophic custom slur regex cannot freeze the chat server', async () =>
@@ -146,7 +146,7 @@ t('a catastrophic custom slur regex cannot freeze the chat server', async () =>
bobWs.sendJson({ type: 'chat', message: line });
bobWs.sendJson({ type: 'chat', message: line });
await viewer.next((m) => m.type === 'chat' && m.message === line, 20000);
await viewer.next((m) => m.type === 'chat' && m.message === line, 20000);
assert.ok(Date.now() - started < 1500, `chat blocked for ${Date.now() - started}ms by a streamer regex`);
assert.ok(Date.now() - started < 1500, `chat blocked for ${Date.now() - started}ms by a streamer regex`);
- h.setChannelSettings(chanX, null);
+ await h.setChannelSettings(chanX, null);
for (const w of [viewer, bobWs]) w.close();
for (const w of [viewer, bobWs]) w.close();
});
});
@@ -49,7 +49,7 @@ t('upload a channel sound: converted to MP3 in the shared sounds dir, listed, se
@@ -49,7 +49,7 @@ t('upload a channel sound: converted to MP3 in the shared sounds dir, listed, se
soundUrl = r.body.sound.url;
soundUrl = r.body.sound.url;
assert.strictEqual(r.body.sound.command, 'honk');
assert.strictEqual(r.body.sound.command, 'honk');
assert.match(soundUrl, /^\/api\/sounds\/file\/snd-.*\.mp3$/);
assert.match(soundUrl, /^\/api\/sounds\/file\/snd-.*\.mp3$/);
- const row = h.db.getChannelSoundById(soundId);
+ const row = await h.db.getChannelSoundById(soundId);
assert.ok(row.url.startsWith(path.resolve(process.env.SOUNDS_PATH)) && fs.existsSync(row.url));
assert.ok(row.url.startsWith(path.resolve(process.env.SOUNDS_PATH)) && fs.existsSync(row.url));
await h.sleep(150);
await h.sleep(150);
assert.ok(h.live.effects.some((e) => e.name === 'asset-sync' && e.body.op === 'syncSoon'), 'Live mirrors it to Media');
assert.ok(h.live.effects.some((e) => e.name === 'asset-sync' && e.body.op === 'syncSoon'), 'Live mirrors it to Media');
@@ -74,25 +74,25 @@ t('!honk in the stream chat plays it (rich announce + audio), persisted', async
@@ -74,25 +74,25 @@ t('!honk in the stream chat plays it (rich announce + audio), persisted', async
const audio = await ws.next((m) => m.type === 'soundboard-audio');
const audio = await ws.next((m) => m.type === 'soundboard-audio');
assert.strictEqual(audio.title, '!honk');
assert.strictEqual(audio.title, '!honk');
assert.ok(audio.audio.length > 100);
assert.ok(audio.audio.length > 100);
- assert.strictEqual(h.db.getChatMessageById(ann.id).message_type, 'channel-sound');
+ assert.strictEqual((await h.db.getChatMessageById(ann.id)).message_type, 'channel-sound');
ws.close();
ws.close();
});
});
t('delete: not someone else’s sound; the uploader can; Live drops the Media copy first', async () => {
t('delete: not someone else’s sound; the uploader can; Live drops the Media copy first', async () => {
assert.strictEqual((await h.http('DELETE', `/api/sounds/${soundId}`, { token: other.token })).status, 403);
assert.strictEqual((await h.http('DELETE', `/api/sounds/${soundId}`, { token: other.token })).status, 403);
- const row = h.db.getChannelSoundById(soundId);
+ const row = await h.db.getChannelSoundById(soundId);
const del = await h.http('DELETE', `/api/sounds/${soundId}`, { token: viewer.token });
const del = await h.http('DELETE', `/api/sounds/${soundId}`, { token: viewer.token });
assert.deepStrictEqual(del.body, { message: 'Sound deleted' });
assert.deepStrictEqual(del.body, { message: 'Sound deleted' });
assert.ok(!fs.existsSync(row.url));
assert.ok(!fs.existsSync(row.url));
assert.ok(h.live.effects.some((e) => e.name === 'asset-sync' && e.body.op === 'remove-sound' && e.body.asset_id === soundId));
assert.ok(h.live.effects.some((e) => e.name === 'asset-sync' && e.body.op === 'remove-sound' && e.body.asset_id === soundId));
- assert.strictEqual(h.db.getChannelSoundById(soundId), undefined);
+ assert.strictEqual(await h.db.getChannelSoundById(soundId), undefined);
});
});
t('alert sounds: the streamer’s own channel, stored on Chat’s own settings row (C-04)', async () => {
t('alert sounds: the streamer’s own channel, stored on Chat’s own settings row (C-04)', async () => {
const r = await upload('/api/sounds/alert/donation', streamer.token, {}, wav());
const r = await upload('/api/sounds/alert/donation', streamer.token, {}, wav());
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.body.kind, 'donation');
assert.strictEqual(r.body.kind, 'donation');
- const row = h.db.get('SELECT donation_sound_url, donation_sound_mime FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
+ const row = await h.db.get('SELECT donation_sound_url, donation_sound_mime FROM channel_moderation_settings WHERE channel_id = ?', [channelId]);
assert.ok(row.donation_sound_url.startsWith(path.resolve(process.env.SOUNDS_PATH)));
assert.ok(row.donation_sound_url.startsWith(path.resolve(process.env.SOUNDS_PATH)));
assert.strictEqual(row.donation_sound_mime, 'audio/mpeg');
assert.strictEqual(row.donation_sound_mime, 'audio/mpeg');
});
});
@@ -28,33 +28,33 @@ t('boot', async () => {
@@ -28,33 +28,33 @@ t('boot', async () => {
t('the folded-in account becomes the survivor in Chat', async () => {
t('the folded-in account becomes the survivor in Chat', async () => {
const d = require('../server/db/database').getDb();
const d = require('../server/db/database').getDb();
// Messages: in otto's channel and in fold's own channel.
// Messages: in otto's channel and in fold's own channel.
- d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'fold', 'hi otto', ?, ?), (?, 'otto', 'hi fold', ?, ?)")
+ await d.prepare("INSERT INTO chat_messages (user_id, username, message, channel_user_id, subject_id) VALUES (?, 'fold', 'hi otto', ?, ?), (?, 'otto', 'hi fold', ?, ?)")
.run(fold.id, other.id, fold.subject_id, other.id, fold.id, other.subject_id);
.run(fold.id, other.id, fold.subject_id, other.id, fold.id, other.subject_id);
// DMs: fold↔otto (only fold), and a group where both keep and fold are.
// DMs: fold↔otto (only fold), and a group where both keep and fold are.
const dmFO = (await h.http('POST', '/api/dm/conversations', { token: fold.token, body: { user_ids: [other.id] } })).body.conversation.id;
const dmFO = (await h.http('POST', '/api/dm/conversations', { token: fold.token, body: { user_ids: [other.id] } })).body.conversation.id;
const group = (await h.http('POST', '/api/dm/conversations', { token: other.token, body: { user_ids: [keep.id, fold.id], name: 'Trio' } })).body.conversation.id;
const group = (await h.http('POST', '/api/dm/conversations', { token: other.token, body: { user_ids: [keep.id, fold.id], name: 'Trio' } })).body.conversation.id;
assert.strictEqual((await h.http('POST', `/api/dm/conversations/${dmFO}/messages`, { token: fold.token, body: { message: 'yo' } })).status < 300, true);
assert.strictEqual((await h.http('POST', `/api/dm/conversations/${dmFO}/messages`, { token: fold.token, body: { message: 'yo' } })).status < 300, true);
- d.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?), (?, ?, ?)').run(fold.id, other.id, fold.subject_id, fold.id, keep.id, fold.subject_id);
+ await d.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?), (?, ?, ?)').run(fold.id, other.id, fold.subject_id, fold.id, keep.id, fold.subject_id);
require('../server/chat/network-blocks').ensureSchema && require('../server/chat/network-blocks').ensureSchema();
require('../server/chat/network-blocks').ensureSchema && require('../server/chat/network-blocks').ensureSchema();
- d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(fold.subject_id, other.subject_id, fold.subject_id, keep.subject_id);
- d.prepare("INSERT INTO moderation_actions (action_type, actor_subject_id) VALUES ('timeout', ?)").run(fold.subject_id);
+ await d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(fold.subject_id, other.subject_id, fold.subject_id, keep.subject_id);
+ await d.prepare("INSERT INTO moderation_actions (action_type, actor_subject_id) VALUES ('timeout', ?)").run(fold.subject_id);
- assert.strictEqual(h.eventsConsumer.apply(merged(fold.subject_id, keep.subject_id, { source: 'live' })).outcome, 'ignored:source');
- assert.strictEqual(h.eventsConsumer.apply({ ...merged(fold.subject_id, keep.subject_id), payload: { merge_id: 'nope' } }).outcome, 'ignored:payload');
+ assert.strictEqual((await h.eventsConsumer.apply(merged(fold.subject_id, keep.subject_id, { source: 'live' }))).outcome, 'ignored:source');
+ assert.strictEqual((await h.eventsConsumer.apply({ ...merged(fold.subject_id, keep.subject_id), payload: { merge_id: 'nope' } })).outcome, 'ignored:payload');
const ev = merged(fold.subject_id, keep.subject_id);
const ev = merged(fold.subject_id, keep.subject_id);
- assert.strictEqual(h.eventsConsumer.apply(ev).outcome, 'merged');
+ assert.strictEqual((await h.eventsConsumer.apply(ev)).outcome, 'merged');
- const q = (sql, ...a) => d.prepare(sql).all(...a);
- assert.deepStrictEqual(q('SELECT user_id AS u, channel_user_id AS c, subject_id AS s FROM chat_messages ORDER BY id').map((m) => [m.u, m.c, m.s]),
+ const q = async (sql, ...a) => await d.prepare(sql).all(...a);
+ assert.deepStrictEqual((await q('SELECT user_id AS u, channel_user_id AS c, subject_id AS s FROM chat_messages ORDER BY id')).map((m) => [m.u, m.c, m.s]),
[[keep.id, other.id, keep.subject_id], [other.id, keep.id, other.subject_id]], 'authorship and the channel follow');
[[keep.id, other.id, keep.subject_id], [other.id, keep.id, other.subject_id]], 'authorship and the channel follow');
- assert.ok(q('SELECT sender_id, sender_subject_id FROM dm_messages').every((m) => m.sender_id === keep.id && m.sender_subject_id === keep.subject_id));
- assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', dmFO).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y));
- assert.deepStrictEqual(q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', group).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y), 'in the group once');
- assert.deepStrictEqual(q('SELECT blocker_id AS a, blocked_id AS b FROM dm_blocks').map((x) => [x.a, x.b]), [[keep.id, other.id]], 'moved; blocking oneself gone');
- assert.deepStrictEqual(q('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks').map((x) => [x.a, x.b]), [[keep.subject_id, other.subject_id]]);
- assert.deepStrictEqual(q('SELECT actor_subject_id AS a FROM moderation_actions').map((x) => x.a), [fold.subject_id], 'moderation history stays as recorded');
+ assert.ok((await q('SELECT sender_id, sender_subject_id FROM dm_messages')).every((m) => m.sender_id === keep.id && m.sender_subject_id === keep.subject_id));
+ assert.deepStrictEqual((await q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', dmFO)).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y));
+ assert.deepStrictEqual((await q('SELECT user_id FROM dm_participants WHERE conversation_id = ? ORDER BY user_id', group)).map((p) => p.user_id), [keep.id, other.id].sort((x, y) => x - y), 'in the group once');
+ assert.deepStrictEqual((await q('SELECT blocker_id AS a, blocked_id AS b FROM dm_blocks')).map((x) => [x.a, x.b]), [[keep.id, other.id]], 'moved; blocking oneself gone');
+ assert.deepStrictEqual((await q('SELECT blocker_subject AS a, blocked_subject AS b FROM network_blocks')).map((x) => [x.a, x.b]), [[keep.subject_id, other.subject_id]]);
+ assert.deepStrictEqual((await q('SELECT actor_subject_id AS a FROM moderation_actions')).map((x) => x.a), [fold.subject_id], 'moderation history stays as recorded');
// The inbox makes a redelivery a no-op.
// The inbox makes a redelivery a no-op.
- assert.strictEqual(h.eventsConsumer.apply(ev).duplicate, true);
+ assert.strictEqual((await h.eventsConsumer.apply(ev)).duplicate, true);
});
});
t.run(async () => { if (h && h.close) await h.close(); });
t.run(async () => { if (h && h.close) await h.close(); });
@@ -63,7 +63,7 @@ async function startVip(keyOf) {
@@ -63,7 +63,7 @@ async function startVip(keyOf) {
const callsFor = (subject) => vip.state.calls.filter((c) => c.subject === subject).length;
const callsFor = (subject) => vip.state.calls.filter((c) => c.subject === subject).length;
async function until(pred, ms = 3000) {
async function until(pred, ms = 3000) {
const end = Date.now() + ms;
const end = Date.now() + ms;
- while (!pred()) { if (Date.now() > end) throw new Error('timed out'); await new Promise((r) => setTimeout(r, 10)); }
+ while (!await pred()) { if (Date.now() > end) throw new Error('timed out'); await new Promise((r) => setTimeout(r, 10)); }
}
}
async function joinRoom(user, ip) {
async function joinRoom(user, ip) {
const ws = await h.ws({ ip, token: user.token, stream: streamId });
const ws = await h.ws({ ip, token: user.token, stream: streamId });
@@ -112,7 +112,7 @@ t('a member\'s message carries the creator\'s badge (looked up at join, so the f
@@ -112,7 +112,7 @@ t('a member\'s message carries the creator\'s badge (looked up at join, so the f
await new Promise((r) => setTimeout(r, 50));
await new Promise((r) => setTimeout(r, 50));
const { msg } = await say(memberWs, watcher);
const { msg } = await say(memberWs, watcher);
assert.deepStrictEqual(msg.vip_badge, { creator: CREATOR, perk: 'subscriber-badge', name: 'Subscriber badge', badge: 'subscriber' });
assert.deepStrictEqual(msg.vip_badge, { creator: CREATOR, perk: 'subscriber-badge', name: 'Subscriber badge', badge: 'subscriber' });
- const row = h.db.getChatMessageById(msg.id);
+ const row = await h.db.getChatMessageById(msg.id);
assert.deepStrictEqual(JSON.parse(row.metadata).vip_badge, msg.vip_badge);
assert.deepStrictEqual(JSON.parse(row.metadata).vip_badge, msg.vip_badge);
assert.ok(h.tokenRequests.some((r) => r.audience === 'openvibe.vip' && r.scope === 'vip.entitlement.check'), 'Chat asked the Network for a VIP token');
assert.ok(h.tokenRequests.some((r) => r.audience === 'openvibe.vip' && r.scope === 'vip.entitlement.check'), 'Chat asked the Network for a VIP token');
const before = callsFor(MEMBER);
const before = callsFor(MEMBER);
@@ -147,7 +147,7 @@ t('sending never waits on VIP: a slow lookup sends at once, the badge follows as
@@ -147,7 +147,7 @@ t('sending never waits on VIP: a slow lookup sends at once, the badge follows as
const follow = await watcher.next((m) => m.type === 'chat_vip_badge' && m.id === msg.id);
const follow = await watcher.next((m) => m.type === 'chat_vip_badge' && m.id === msg.id);
// Creator-written binding config is cleaned: an unknown badge id becomes "member", the label is plain text.
// Creator-written binding config is cleaned: an unknown badge id becomes "member", the label is plain text.
assert.deepStrictEqual(follow.vip_badge, { creator: CREATOR, perk: 'subscriber-badge', name: 'Subscriber badge', badge: 'member', label: 'bCrew/b' });
assert.deepStrictEqual(follow.vip_badge, { creator: CREATOR, perk: 'subscriber-badge', name: 'Subscriber badge', badge: 'member', label: 'bCrew/b' });
- await until(() => { const r = h.db.getChatMessageById(msg.id); return r.metadata && JSON.parse(r.metadata).vip_badge; });
+ await until(async () => { const r = await h.db.getChatMessageById(msg.id); return r.metadata && JSON.parse(r.metadata).vip_badge; });
const next = await say(ws, watcher);
const next = await say(ws, watcher);
assert.strictEqual(next.msg.vip_badge.badge, 'member', 'cached from then on');
assert.strictEqual(next.msg.vip_badge.badge, 'member', 'cached from then on');
});
});
@@ -25,7 +25,7 @@ t('boot', async () => {
@@ -25,7 +25,7 @@ t('boot', async () => {
alice = h.addUser('alice', { subject: ids.newId('user') });
alice = h.addUser('alice', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
carol = h.addUser('carol', { subject: ids.newId('user') });
carol = h.addUser('carol', { subject: ids.newId('user') });
- for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
+ for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
});
});
t('global chat reads without JavaScript; signing in shows the composer', async () => {
t('global chat reads without JavaScript; signing in shows the composer', async () => {
@@ -22,10 +22,10 @@ t('boot Chat against a stub Live', async () => {
@@ -22,10 +22,10 @@ t('boot Chat against a stub Live', async () => {
bob = h.addUser('bob');
bob = h.addUser('bob');
admin = h.addUser('boss', { role: 'admin' });
admin = h.addUser('boss', { role: 'admin' });
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
- h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)
+ await h.db.addChannelModerator(channelId, mod.id, streamer.id); // the moderator row is Chat's own (C-04)
streamId = h.addStream(streamer.id, channelId, { managed: { id: 71, slug: 'main-slot', title: 'Main slot' } });
streamId = h.addStream(streamer.id, channelId, { managed: { id: 71, slug: 'main-slot', title: 'Main slot' } });
await h.ctx.sync();
await h.ctx.sync();
- assert.ok(h.ctx.getStreamById(streamId), 'stream projected');
+ assert.ok(await h.ctx.getStreamById(streamId), 'stream projected');
});
});
t('anonymous connect, join global → auth as an anon number from Live', async () => {
t('anonymous connect, join global → auth as an anon number from Live', async () => {
@@ -91,12 +91,12 @@ t('chat into a stream room: room delivery, global cross-feed, persistence, outbo
@@ -91,12 +91,12 @@ t('chat into a stream room: room delivery, global cross-feed, persistence, outbo
// First chat in the channel → welcome line to the room.
// First chat in the channel → welcome line to the room.
await bobWs.next((m) => m.type === 'system' && /^Welcome Alice to the chat!/.test(m.message));
await bobWs.next((m) => m.type === 'system' && /^Welcome Alice to the chat!/.test(m.message));
// Persisted with Live's ids and the Network subject.
// Persisted with Live's ids and the Network subject.
- const row = h.db.getChatMessageById(msgId);
+ const row = await h.db.getChatMessageById(msgId);
assert.strictEqual(row.user_id, alice.id);
assert.strictEqual(row.user_id, alice.id);
assert.strictEqual(row.channel_user_id, streamer.id);
assert.strictEqual(row.channel_user_id, streamer.id);
assert.strictEqual(row.subject_id, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');
assert.strictEqual(row.subject_id, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');
// Outbox envelope in the same transaction.
// Outbox envelope in the same transaction.
- const ev = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.message.created'").map((r) => JSON.parse(r.event)).find((e) => e.payload.message_id === msgId);
+ const ev = (await h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.message.created'")).map((r) => JSON.parse(r.event)).find((e) => e.payload.message_id === msgId);
assert.ok(ev, 'chat.message.created in the outbox');
assert.ok(ev, 'chat.message.created in the outbox');
assert.strictEqual(ev.visibility, 'public');
assert.strictEqual(ev.visibility, 'public');
assert.deepStrictEqual(ev.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
assert.deepStrictEqual(ev.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
@@ -168,12 +168,12 @@ t('DM delivery keeps the participant check (REST send → live socket; a non-par
@@ -168,12 +168,12 @@ t('DM delivery keeps the participant check (REST send → live socket; a non-par
const eveWs = await h.ws({ ip: '198.51.100.30', token: eve.token });
const eveWs = await h.ws({ ip: '198.51.100.30', token: eve.token });
eveWs.sendJson({ type: 'join', token: eve.token });
eveWs.sendJson({ type: 'join', token: eve.token });
await eveWs.next((m) => m.type === 'auth');
await eveWs.next((m) => m.type === 'auth');
- h.chatServer.sendDm(eve.id, { type: 'dm', conversation_id: convId, message: { message: 'leak' } });
+ await h.chatServer.sendDm(eve.id, { type: 'dm', conversation_id: convId, message: { message: 'leak' } });
assert.ok(await eveWs.none((m) => m.type === 'dm'), 'participant check held');
assert.ok(await eveWs.none((m) => m.type === 'dm'), 'participant check held');
assert.strictEqual((await h.http('GET', `/api/dm/conversations/${convId}/messages`, { token: eve.token })).status, 403);
assert.strictEqual((await h.http('GET', `/api/dm/conversations/${convId}/messages`, { token: eve.token })).status, 403);
// Offline notification went to Live; the DM event is subject-scoped and carries no text.
// Offline notification went to Live; the DM event is subject-scoped and carries no text.
assert.ok(h.live.effects.some((e) => e.name === 'notify/dm' && e.body.recipient_ids.includes(bob.id)));
assert.ok(h.live.effects.some((e) => e.name === 'notify/dm' && e.body.recipient_ids.includes(bob.id)));
- const ev = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.dm.created'").map((r) => JSON.parse(r.event)).pop();
+ const ev = (await h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.dm.created'")).map((r) => JSON.parse(r.event)).pop();
assert.strictEqual(ev.visibility, 'subject');
assert.strictEqual(ev.visibility, 'subject');
assert.ok(!JSON.stringify(ev).includes('secret hello'), 'no DM text in the event');
assert.ok(!JSON.stringify(ev).includes('secret hello'), 'no DM text in the event');
eveWs.close();
eveWs.close();
@@ -225,12 +225,12 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asy
@@ -225,12 +225,12 @@ t('moderation: only moderators; /ban goes to Live and takes effect at once', asy
assert.strictEqual((await aliceWs.next((m) => m.type === 'slowmode')).seconds, 5);
assert.strictEqual((await aliceWs.next((m) => m.type === 'slowmode')).seconds, 5);
await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow mode enabled: 5s between messages');
await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow mode enabled: 5s between messages');
await h.sleep(100);
await h.sleep(100);
- assert.strictEqual(h.ctx.getChannelModerationSettings(channelId).slow_mode_seconds, 5, 'saved on Chat’s own row');
- assert.strictEqual(h.chatServer.slowModeByStream.get(streamId), 5000);
+ assert.strictEqual((await h.ctx.getChannelModerationSettings(channelId)).slow_mode_seconds, 5, 'saved on Chat’s own row');
+ assert.strictEqual((await h.chatServer.slowModeByStream()).get(streamId), 5000);
// Every action is logged (and announced as an internal moderation event).
// Every action is logged (and announced as an internal moderation event).
- const actions = h.db.all('SELECT action_type FROM moderation_actions ORDER BY id').map((r) => r.action_type);
+ const actions = (await h.db.all('SELECT action_type FROM moderation_actions ORDER BY id')).map((r) => r.action_type);
for (const a of ['channel_ban', 'channel_unban', 'channel_timeout', 'slowmode_update', 'clear_chat']) assert.ok(actions.includes(a), a);
for (const a of ['channel_ban', 'channel_unban', 'channel_timeout', 'slowmode_update', 'clear_chat']) assert.ok(actions.includes(a), a);
- const modEv = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'").map((r) => JSON.parse(r.event));
+ const modEv = (await h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'")).map((r) => JSON.parse(r.event));
assert.ok(modEv.length >= 5 && modEv.every((e) => e.visibility === 'internal'));
assert.ok(modEv.length >= 5 && modEv.every((e) => e.visibility === 'internal'));
aliceWs.sendJson({ type: 'chat', message: 'slow one' });
aliceWs.sendJson({ type: 'chat', message: 'slow one' });
await bobWs.next((m) => m.type === 'chat' && m.message === 'slow one');
await bobWs.next((m) => m.type === 'chat' && m.message === 'slow one');