openvibers/openvibe.chat Commits
N-1 tests (roadmap WS-P task 11): the previous release's clients against this server, and its SQL against this schema. test/n-1.test.js boots this checkout with its own test/helpers.js (stub Network and Live) on a database created from the previous release's schema, so Chat's migrations run over it, and replays test/fixtures/n-1/client.json inside openvibe-shared's mixed-version matrix (release-compat assertMixedVersion): 104 requests (Live's chat widget, messenger and pickers at Live 8caa540 on the paths nginx sends to Chat, Chat's own pages' scripts, links and forms, the openvibe-sdk chat client; every call anonymously, reads signed in), each answered compatibly (status, JSON, every response field the client reads with a type it had), and two /ws/chat sessions (anonymous and signed in) sending the six message types Live's chat and Chat's page send (join, get-users, chat, join_room, room_message, self-delete-history): every reply type the client handles must still come, with the fields it reads. Then every statement in test/fixtures/n-1/worker.json (222: what that release ran, plus its SQL literals) must still prepare on the migrated schema, and no old INSERT may miss a new NOT NULL column without a default. Fixtures recorded from 739d3a1, the release in production; refresh after each deploy with `N1_LIVE_REF=<live sha> npm run n-1:record [ref]` (a temporary worktree of the release, booted, seeded, replayed; Live's files from N1_LIVE_REPO, default ../OpenVibe.Live). The harness is the file Live, Media and Community share (test/n-1/harness.js); test/n-1/service.js is Chat's part. In npm test, so in CI, about 6 s, no git history or network. Checked by breaking it: renaming users-list's users field fails it.
Changes
@@@ -4,7 +4,9 @@ on:branches: [main]pull_request:jobs:-- # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).++ # install, node --check, npm test (with test/n-1.test.js: the previous release's clients, Live's chat++ # widget among them, and its SQL against this one, from test/fixtures/n-1) and openvibe-contracts-check:++ # the shared test job (Track Q).test:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:@@@ -325,8 +325,19 @@ node scripts/table-authority.js # who writes each staged table (the hanode scripts/mirror-flush.js # rollback helper: push queued mirror rows to Livenode scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones)node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md)++N1_LIVE_REF=<live sha> npm run n-1:record # after a deploy: the N-1 fixtures from the deployed commit```++`test/n-1.test.js` (roadmap WS-P task 11, in `npm test`) runs the previous release's clients against++this one and its SQL against this schema, from `test/fixtures/n-1/`: every call Chat's pages, the++openvibe.chat pages' links, scripts and forms, and Live's chat widget, messenger and pickers make++(status, JSON, the fields they read), the `/ws/chat` messages Live's chat sends with the replies it++reads, and every statement the previous release runs, which must still prepare after this release++migrated a database the previous one created. After each deploy,++record the release now in production as the next N-1 (`npm run n-1:record [ref]`, with `N1_LIVE_REF`++the Live release in production, from a Live checkout at `N1_LIVE_REPO`, default `../OpenVibe.Live`)++and commit the fixtures.++`/ready` (openvibe-shared/ready shape: `status` ready/degraded/not_ready and `checks`) is 503 onlywhen the `db` check (a read of `chat_messages`) fails. `live_sync` is optional: it reads degraded whenthe last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a sync step has missed@@@ -352,7 +363,7 @@ server/net/service-auth.js service tokens: client (Chat → others) and guard (oserver/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycleserver/db/ schema.sql, database.js (Live's chat functions, same names and arguments)--scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events++scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events, n-1-recorddocs/ cutover.md, calls-cutover.md, staged-tables-cutover.md, parity.md, live-patch.diff, capabilities-proposal/```@@@ -8,7 +8,8 @@"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js","test": "node test/run.js",-- "import-from-live": "node scripts/import-from-live.js"++ "import-from-live": "node scripts/import-from-live.js",++ "n-1:record": "node scripts/n-1-record.js"},"keywords": ["openvibe",nnew file mode 100644@@@ -0,0 +1,130 @@++#!/usr/bin/env node++'use strict';++/**++ * Records the N-1 fixtures (roadmap WS-P task 11) from a release: what its client calls and reads,++ * and what SQL it runs on which schema. test/n-1.test.js replays them against this checkout.++ *++ * npm run n-1:record # from HEAD: run it right after a deploy, from the deployed commit,++ * # so the fixture is N-1 for the next release++ * npm run n-1:record -- <ref> # from another commit (e.g. the sha in production's /release.json)++ *++ * The release is checked out into a temporary git worktree (sharing node_modules), seeded and booted++ * 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.++ */++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 ROOT = path.join(__dirname, '..');++const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');++++(async () => {++ 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 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})`);++++ // 1. The client: its calls, and the names it reads.++ const files = svc.clientFiles(wt.dir);++ const { calls, dynamic } = h.extractCalls({ files, callers: svc.callers, strip: svc.strip, keep: svc.keep, forms: !!svc.forms });++ const ids = h.clientIdentifiers(files.map((f) => f.text));++ console.log(` client: ${files.length} files, ${calls.length} call sites with a path (${dynamic} with a computed one, not replayed)`);++++ // 2. The release, seeded and booted; every call replayed.++ await svc.seed({ dir: wt.dir, dbPath, dataDir });++ server = await svc.boot({ dir: wt.dir, dbPath, dataDir, sqlOut });++ const manifest = await (await fetch(`${server.url}/release.json`)).json();++ const probe = await h.notFoundProbe(server.url);++ const requests = h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false });++ // Server-rendered pages: an open tab holds the page N-1 served, and follows what it links to.++ if (svc.crawl) {++ const have = new Set(requests.map((r) => `${r.method} ${r.path} ${r.auth}`));++ const push = (r) => { const k = `${r.method} ${r.path} ${r.auth}`; if (!have.has(k)) { have.add(k); requests.push(r); } };++ for (const page of svc.crawl) {++ push({ method: 'GET', path: page, auth: 'anon', accept: 'html', from: ['page'] });++ const got = await h.send(server.url, { method: 'GET', path: h.fillPath(page, server.ids), headers: { accept: 'text/html' } });++ for (const l of got.html ? h.pageLinks(got.html, svc.origins) : []) {++ const p = h.unfillPath(l.path, server.ids);++ if (!svc.keep || svc.keep(p.split('?')[0], l.method, `page ${page}`)) push({ method: l.method, path: p, auth: 'anon', accept: 'html', from: [`page ${page}`] });++ }++ }++ requests.sort((a, b) => (a.method === 'GET' ? 0 : 1) - (b.method === 'GET' ? 0 : 1));++ }++ const recorded = [];++ let unanswered = 0; let noRoute = 0;++ for (const req of requests) {++ const got = await h.send(server.url, { method: req.method, path: h.fillPath(req.path, server.ids), headers: { ...(req.accept ? { accept: 'text/html' } : {}), ...server.headers(req.auth) }, body: req.method === 'GET' ? undefined : {} });++ if (got.status === 0) { unanswered++; continue; }++ // No route for this path on N-1 itself (a sample value its route does not take, or a dead call):++ // nothing N has to keep.++ if (h.isNoRoute(got, probe)) { noRoute++; continue; }++ const rec = { method: req.method, path: req.path, auth: req.auth, ...(req.accept ? { accept: req.accept } : {}), status: got.status, kind: got.kind };++ if (got.location) rec.location = got.location;++ if (got.json != null && got.status >= 200 && got.status < 300) {++ const reads = h.readsOf(got.json, ids);++ if (Object.keys(reads).length) rec.reads = reads;++ }++ rec.from = req.from;++ recorded.push(rec);++ }++ console.log(` replayed: ${recorded.length} requests (left out: ${unanswered} unanswered, ${noRoute} with no route on N-1)`);++++ // Sockets: the messages the client sends, in one session per sign-in state; what comes back.++ let ws = null;++ if (svc.ws) {++ const wsFiles = svc.ws.files ? files.filter((f) => svc.ws.files(f.name)) : files;++ const sends = h.wsSends(wsFiles, svc.ws.helpers);++ const handled = h.wsHandled(wsFiles);++ ws = { path: svc.ws.path, sends, sessions: [] };++ for (const auth of ['anon', 'user']) {++ const messages = h.wsMessages(sends, { order: svc.ws.order, values: server.wsValues(auth) });++ const received = await h.wsSession({ url: server.url.replace(/^http/, 'ws') + svc.ws.path, headers: svc.ws.headers, messages });++ ws.sessions.push({ auth, received: h.wsReads(received.filter((m) => !(svc.ws.ignore || []).includes(m.type)), handled, ids) });++ }++ console.log(` sockets: ${sends.length} message types sent; received, of the types the client handles: ${ws.sessions.map((x) => `${x.auth} ${Object.keys(x.received).length}`).join(', ')}`);++ }++ await server.close();++ server = null;++++ // 3. The schema N-1 left, and the SQL it runs on it.++ const db = new Database(dbPath);++ const schema = h.schemaDDL(db);++ const ledger = {};++ for (const t of svc.ledgerTables || []) {++ try { ledger[t] = db.prepare(`SELECT * FROM "${t}"`).all(); } catch { /* not there */ }++ }++ const userVersion = db.pragma('user_version', { simple: true });++ const serverFiles = h.readTree(wt.dir, svc.sqlDirs);++ // What N-1 creates itself on first use or at a full boot: in production it exists before N deploys.++ const lazy = h.lazyDDL(serverFiles, db);++ const ran = fs.existsSync(sqlOut) ? JSON.parse(fs.readFileSync(sqlOut, 'utf8')) : [];++ const candidates = [...new Set([...ran.map(h.normalizeSql).filter((s) => /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i.test(s)), ...h.sqlLiterals(serverFiles)])].sort();++ const failing = new Set(h.prepareProblems(db, candidates).map((p) => p.sql));++ const statements = candidates.filter((s) => !failing.has(s));++ db.close();++ console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${schema.length} schema objects, ${lazy.length} created on first use`);++++ fs.mkdirSync(OUT, { recursive: true });++ 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`);++ fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);++ console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);++ } finally {++ if (server) await server.close();++ wt.remove();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,2012 @@++{++ "service": "chat",++ "release": "739d3a1cdc22fbb0717e4df4abd6b7e4ad31a8f1",++ "recorded_at": "2026-09-26T14:07:10.965Z",++ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",++ "clients": {++ "live": "8caa540466f9af86247febdbba1339b68919a42f"++ },++ "manifest": {++ "service": "chat",++ "release": "739d3a1cdc22",++ "released_at": "2026-09-26T13:12:38.000Z",++ "booted_at": "2026-09-26T14:07:06.104Z",++ "contracts_version": "0.57.0",++ "packages": {++ "openvibe-shared": "1.22.0",++ "openvibe-sdk": "0.11.0",++ "openvibe-contracts": "0.57.0"++ },++ "min_client_release": null,++ "mixed_version_window_hours": 24,++ "components": {++ "shell": {++ "kind": "script",++ "version": "739d3a1cdc22"++ },++ "server": {++ "kind": "server",++ "version": "739d3a1cdc22"++ }++ },++ "assets": {},++ "schema_generation": null,++ "schema_compatible_from": null,++ "contract_ranges": {},++ "metrics_url": "/release-metrics"++ },++ "dynamic_calls": 53,++ "calls": [++ {++ "method": "GET",++ "path": "/api/chat/admin/logs",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/dashboard.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/admin/logs",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "rows": "array",++ "total": "number",++ "page": "number",++ "limit": "number",++ "totalPages": "number"++ },++ "from": [++ "OpenVibe.Live:public/js/dashboard.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/anon/n1fan",++ "auth": "anon",++ "status": 400,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/anon/n1fan",++ "auth": "user",++ "status": 400,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/channel/1001/history?limit=500",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_is_live": "null",++ "messages[].source_slug": "null",++ "messages[].source_channel": "string",++ "latest_id": "number",++ "liveSlots": "array",++ "channel": "string",++ "activeStreamId": "null",++ "activeManagedId": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/channel/1001/history?limit=500",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_is_live": "null",++ "messages[].source_slug": "null",++ "messages[].source_channel": "string",++ "latest_id": "number",++ "liveSlots": "array",++ "channel": "string",++ "activeStreamId": "null",++ "activeManagedId": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/filters/friendly",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "categories": "array",++ "categories[]": "object",++ "categories[].key": "string",++ "categories[].label": "string",++ "categories[].patterns": "array",++ "categories[].patterns[]": "string"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/filters/friendly",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "categories": "array",++ "categories[]": "object",++ "categories[].key": "string",++ "categories[].label": "string",++ "categories[].patterns": "array",++ "categories[].patterns[]": "string"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/gif/providers",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "providers": "object",++ "providers.tenor": "boolean",++ "providers.giphy": "boolean",++ "defaultProvider": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/emotes.js",++ "OpenVibe.Live:public/js/gif-picker.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/gif/providers",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "providers": "object",++ "providers.tenor": "boolean",++ "providers.giphy": "boolean",++ "defaultProvider": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/emotes.js",++ "OpenVibe.Live:public/js/gif-picker.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/gif/search?provider=tenor&q=n1",++ "auth": "anon",++ "status": 503,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/gif-picker.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/gif/search?provider=tenor&q=n1",++ "auth": "user",++ "status": 503,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/gif-picker.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/gif/trending?provider=tenor",++ "auth": "anon",++ "status": 503,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/gif-picker.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/gif/trending?provider=tenor",++ "auth": "user",++ "status": 503,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/gif-picker.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/global/history",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].stream_channel": "string",++ "messages[].source_is_live": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_slug": "null",++ "latest_id": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/global/history",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].stream_channel": "string",++ "messages[].source_is_live": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_slug": "null",++ "latest_id": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/global/history?after_id=&limit=200",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].stream_channel": "string",++ "messages[].source_is_live": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_slug": "null",++ "latest_id": "number",++ "complete": "boolean",++ "deleted_ids": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/global/history?after_id=&limit=200",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].stream_channel": "string",++ "messages[].source_is_live": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_slug": "null",++ "latest_id": "number",++ "complete": "boolean",++ "deleted_ids": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/global/history?after_id=0&limit=200",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].stream_channel": "string",++ "messages[].source_is_live": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_slug": "null",++ "latest_id": "number",++ "complete": "boolean",++ "deleted_ids": "array"++ },++ "from": [++ "public/web/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/global/history?after_id=0&limit=200",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].stream_channel": "string",++ "messages[].source_is_live": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_slug": "null",++ "latest_id": "number",++ "complete": "boolean",++ "deleted_ids": "array"++ },++ "from": [++ "public/web/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/global/history?limit=500",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].stream_channel": "string",++ "messages[].source_is_live": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_slug": "null",++ "latest_id": "number"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/global/history?limit=500",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].stream_channel": "string",++ "messages[].source_is_live": "null",++ "messages[].source_managed_id": "null",++ "messages[].source_stream_title": "null",++ "messages[].source_slug": "null",++ "latest_id": "number"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/ice-servers",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "iceServers": "array",++ "iceServers[]": "object",++ "iceServers[].urls": "string"++ },++ "from": [++ "public/web/call.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/ice-servers",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "iceServers": "array",++ "iceServers[]": "object",++ "iceServers[].urls": "string"++ },++ "from": [++ "public/web/call.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/me/export",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/me/export",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "username": "string",++ "total": "number",++ "messages": "array"++ },++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/relay-user/1/n1fan",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "relayUser": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/relay-user/1/n1fan",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "relayUser": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/rooms",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "public": "array",++ "public[]": "object",++ "public[].id": "number",++ "public[].slug": "string",++ "public[].name": "string",++ "public[].topic": "null",++ "public[].kind": "string",++ "public[].visibility": "string",++ "public[].slow_seconds": "number",++ "public[].message_count": "number",++ "public[].last_message_at": "string",++ "public[].created_at": "string",++ "public[].members": "number",++ "mine": "array"++ },++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/rooms",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "public": "array",++ "public[]": "object",++ "public[].id": "number",++ "public[].slug": "string",++ "public[].name": "string",++ "public[].topic": "null",++ "public[].kind": "string",++ "public[].visibility": "string",++ "public[].slow_seconds": "number",++ "public[].message_count": "number",++ "public[].last_message_at": "string",++ "public[].created_at": "string",++ "public[].members": "number",++ "mine": "array",++ "mine[]": "object",++ "mine[].id": "number",++ "mine[].slug": "string",++ "mine[].name": "string",++ "mine[].topic": "null",++ "mine[].kind": "string",++ "mine[].visibility": "string",++ "mine[].slow_seconds": "number",++ "mine[].message_count": "number",++ "mine[].last_message_at": "string",++ "mine[].created_at": "string",++ "mine[].members": "number",++ "mine[].role": "string",++ "mine[].unread": "number",++ "mine[].last_seen_at": "string"++ },++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/rooms/n1-room/messages?after=0&limit=200",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].user_id": "number",++ "messages[].message": "string",++ "messages[].created_at": "string",++ "messages[].is_deleted": "number",++ "messages[].username": "string",++ "messages[].display_name": "string",++ "messages[].profile_color": "string",++ "messages[].user_role": "string",++ "latest_id": "number"++ },++ "from": [++ "public/web/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/rooms/n1-room/messages?after=0&limit=200",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].user_id": "number",++ "messages[].message": "string",++ "messages[].created_at": "string",++ "messages[].is_deleted": "number",++ "messages[].username": "string",++ "messages[].display_name": "string",++ "messages[].profile_color": "string",++ "messages[].user_role": "string",++ "latest_id": "number"++ },++ "from": [++ "public/web/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/search",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/search",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "total": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/user/n1fan/profile",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "id": "number",++ "username": "string",++ "display_name": "string",++ "messageCount": "number",++ "followerCount": "number"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/user/n1fan/profile",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "id": "number",++ "username": "string",++ "display_name": "string",++ "messageCount": "number",++ "followerCount": "number"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/500/history?limit=500",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].source_stream_title": "string",++ "messages[].source_managed_id": "null",++ "messages[].source_is_live": "number",++ "messages[].source_slug": "null",++ "messages[].source_channel": "string",++ "latest_id": "number",++ "complete": "boolean",++ "liveSlots": "array",++ "channel": "string",++ "activeStreamId": "number",++ "activeManagedId": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/500/history?limit=500",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null",++ "messages[].core_username": "null",++ "messages[].source_stream_title": "string",++ "messages[].source_managed_id": "null",++ "messages[].source_is_live": "number",++ "messages[].source_slug": "null",++ "messages[].source_channel": "string",++ "latest_id": "number",++ "complete": "boolean",++ "liveSlots": "array",++ "channel": "string",++ "activeStreamId": "number",++ "activeManagedId": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/500/replay",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/app-media.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat/500/replay",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array",++ "messages[]": "object",++ "messages[].id": "number",++ "messages[].stream_id": "number",++ "messages[].user_id": "number",++ "messages[].anon_id": "null",++ "messages[].username": "string",++ "messages[].message": "string",++ "messages[].message_type": "string",++ "messages[].metadata": "null",++ "messages[].is_global": "number",++ "messages[].is_deleted": "number",++ "messages[].reply_to_id": "null",++ "messages[].source_platform": "null",++ "messages[].timestamp": "string",++ "messages[].channel_user_id": "number",++ "messages[].avatar_url": "null",++ "messages[].profile_color": "null",++ "messages[].role": "null",++ "messages[].display_name": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/app-media.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/blocks",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js",++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/blocks",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "blocked": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js",++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/blocks/check/1002",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/blocks/check/1002",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "blocked": "boolean"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js",++ "node_modules/openvibe-sdk/src/chat.js",++ "public/web/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "conversations": "array",++ "conversations[]": "object",++ "conversations[].id": "number",++ "conversations[].name": "null",++ "conversations[].is_group": "number",++ "conversations[].updated_at": "string",++ "conversations[].last_message": "string",++ "conversations[].last_message_at": "string",++ "conversations[].unread_count": "number",++ "conversations[].participants": "array",++ "conversations[].participants[]": "object",++ "conversations[].participants[].id": "number",++ "conversations[].participants[].username": "string",++ "conversations[].participants[].display_name": "string",++ "conversations[].participants[].avatar_url": "null",++ "conversations[].participants[].profile_color": "string"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js",++ "node_modules/openvibe-sdk/src/chat.js",++ "public/web/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "conversation": "object",++ "conversation.id": "number",++ "conversation.name": "null",++ "conversation.is_group": "number",++ "conversation.created_by": "number",++ "conversation.created_at": "string",++ "conversation.updated_at": "string",++ "conversation.participants": "array",++ "conversation.participants[]": "object",++ "conversation.participants[].id": "number",++ "conversation.participants[].username": "string",++ "conversation.participants[].display_name": "string",++ "conversation.participants[].avatar_url": "null",++ "conversation.participants[].profile_color": "string"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1/messages?after=0&limit=50",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1/messages?after=0&limit=50",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1/messages?limit=50&before=0",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1/messages?limit=50&before=0",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1/messages?limit=50",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1/messages?limit=50",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1/messages?after=0&limit=100",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "public/web/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/conversations/1/messages?after=0&limit=100",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "messages": "array"++ },++ "from": [++ "public/web/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/unread",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js",++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/unread",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "unread": "number"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js",++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/users/search?q=",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/users/search?q=",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "users": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/users/search?q=n1fan",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/dm/users/search?q=n1fan",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "users": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/sounds/alert/mine",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/channel-uploads.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/sounds/alert/mine",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "donation": "object",++ "donation.set": "boolean",++ "donation.url": "null",++ "goal": "object",++ "goal.set": "boolean",++ "goal.url": "null"++ },++ "from": [++ "OpenVibe.Live:public/js/channel-uploads.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/sounds/all/1",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "sounds": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/emotes.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/sounds/all/1",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "sounds": "array"++ },++ "from": [++ "OpenVibe.Live:public/js/emotes.js"++ ]++ },++ {++ "method": "GET",++ "path": "/",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page"++ ]++ },++ {++ "method": "GET",++ "path": "/web/chat.css?v=f8b0b316ea",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/theme-loader.js?v=d9b9af081efe",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/navbar.js?v=b50d9f47fc34",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/footer.js?v=b99e512398d5",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/web/chat.js?v=aedc88ef89",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/rooms",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/messages",++ "auth": "anon",++ "accept": "html",++ "status": 303,++ "kind": "html",++ "location": "/auth/login",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/settings",++ "auth": "anon",++ "accept": "html",++ "status": 303,++ "kind": "html",++ "location": "/auth/login",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/auth/login?next=%2F",++ "auth": "anon",++ "accept": "html",++ "status": 302,++ "kind": "html",++ "location": "/oauth/authorize",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/sitemap.xml",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/updates",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/auth/login?next=%2Frooms",++ "auth": "anon",++ "accept": "html",++ "status": 302,++ "kind": "html",++ "location": "/oauth/authorize",++ "from": [++ "page /rooms"++ ]++ },++ {++ "method": "GET",++ "path": "/r/{@room}",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /rooms"++ ]++ },++ {++ "method": "GET",++ "path": "/r/n1-room",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page"++ ]++ },++ {++ "method": "GET",++ "path": "/auth/login?next=%2Fr%2F{@room}",++ "auth": "anon",++ "accept": "html",++ "status": 302,++ "kind": "html",++ "location": "/oauth/authorize",++ "from": [++ "page /r/n1-room"++ ]++ },++ {++ "method": "GET",++ "path": "/auth/login?next=%2Fupdates",++ "auth": "anon",++ "accept": "html",++ "status": 302,++ "kind": "html",++ "location": "/oauth/authorize",++ "from": [++ "page /updates"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/shipped.js?v=18e4e7ae4ec3",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /updates"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/chat/admin/purge",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/dashboard.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/chat/admin/purge/preview",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/dashboard.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/chat/rooms",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/chat/rooms/n1-room/messages/1",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "public/web/chat.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/chat/rooms/n1-room/read",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "public/web/chat.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/chat/send",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/dm/blocks/1002",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/dm/blocks/1002",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/dm/conversations",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js",++ "node_modules/openvibe-sdk/src/chat.js"++ ]++ },++ {++ "method": "PATCH",++ "path": "/api/dm/conversations/1",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/dm/conversations/1/messages",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/dm/conversations/1/messages/1",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/dm/conversations/1/participants",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/dm/conversations/1/participants/1002",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/dm/conversations/1/read",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/messenger.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/sounds",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/channel-uploads.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/sounds/alert/follow",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/channel-uploads.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/sounds/alert/follow",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/channel-uploads.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/sounds/1",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/channel-uploads.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/tts/admin/test",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "OpenVibe.Live:public/js/broadcast.js"++ ]++ }++ ],++ "ws": {++ "path": "/ws/chat?stream=500",++ "sends": [++ {++ "type": "chat",++ "keys": [++ "message",++ "streamId"++ ]++ },++ {++ "type": "get-users",++ "keys": []++ },++ {++ "type": "join",++ "keys": [++ "channelUserId",++ "streamId",++ "token"++ ]++ },++ {++ "type": "join_room",++ "keys": [++ "room"++ ]++ },++ {++ "type": "room_message",++ "keys": [++ "message"++ ]++ },++ {++ "type": "self-delete-history",++ "keys": [++ "streamId"++ ]++ }++ ],++ "sessions": [++ {++ "auth": "anon",++ "received": {++ "auth": {++ "[].type": "string",++ "[].authenticated": "boolean",++ "[].newcomer": "boolean",++ "[].username": "string",++ "[].core_username": "null",++ "[].role": "string",++ "[].user_id": "null",++ "[].slowmode_seconds": "number",++ "[].sub_only": "boolean",++ "[].allow_auto_delete": "boolean",++ "[].allow_self_delete_all": "boolean",++ "[].gifs_enabled": "boolean",++ "[].custom_emotes_enabled": "boolean",++ "[].custom_sounds_enabled": "boolean",++ "[].uploads_mods_only": "boolean",++ "[].max_sound_seconds": "number",++ "[].emote_scale": "number",++ "[].soundboard_enabled": "boolean",++ "[].soundboard_allow_pitch": "boolean",++ "[].soundboard_allow_speed": "boolean",++ "[].slur_filter_enabled": "boolean",++ "[].slur_filter_use_builtin": "boolean",++ "[].slur_filter_disabled_categories": "array",++ "[].slur_filter_terms": "array",++ "[].slur_filter_regexes": "array",++ "[].slur_filter_nudge_message": "string",++ "[].min_auto_delete_minutes": "number",++ "[].channel_language": "string"++ },++ "chat": {++ "[].type": "string",++ "[].username": "string",++ "[].core_username": "null",++ "[].user_id": "null",++ "[].anon_id": "string",++ "[].role": "string",++ "[].message": "string",++ "[].stream_id": "number",++ "[].channel_user_id": "number",++ "[].is_global": "boolean",++ "[].avatar_url": "null",++ "[].profile_color": "string",++ "[].timestamp": "string",++ "[].id": "number"++ },++ "delete-messages": {++ "[].type": "string",++ "[].ids": "array",++ "[].ids[]": "number"++ },++ "room_error": {++ "[].type": "string",++ "[].room": "string",++ "[].code": "string",++ "[].message": "string"++ },++ "room_joined": {++ "[].type": "string",++ "[].room": "string",++ "[].kind": "string",++ "[].role": "null",++ "[].can": "object",++ "[].can.post": "boolean",++ "[].can.moderate": "boolean",++ "[].can.manage": "boolean",++ "[].can.join": "boolean",++ "[].can.talk": "boolean"++ },++ "self-delete-result": {++ "[].type": "string",++ "[].count": "number",++ "[].scope": "string"++ },++ "system": {++ "[].type": "string",++ "[].message": "string",++ "[].timestamp": "string"++ },++ "user-count": {++ "[].type": "string",++ "[].count": "number",++ "[].stream_id": "number"++ },++ "users-list": {++ "[].type": "string",++ "[].users": "object",++ "[].users.logged": "array",++ "[].users.anonCount": "number"++ }++ }++ },++ {++ "auth": "user",++ "received": {++ "auth": {++ "[].type": "string",++ "[].authenticated": "boolean",++ "[].newcomer": "boolean",++ "[].username": "string",++ "[].core_username": "string",++ "[].role": "string",++ "[].user_id": "number",++ "[].slowmode_seconds": "number",++ "[].sub_only": "boolean",++ "[].allow_auto_delete": "boolean",++ "[].allow_self_delete_all": "boolean",++ "[].gifs_enabled": "boolean",++ "[].custom_emotes_enabled": "boolean",++ "[].custom_sounds_enabled": "boolean",++ "[].uploads_mods_only": "boolean",++ "[].max_sound_seconds": "number",++ "[].emote_scale": "number",++ "[].soundboard_enabled": "boolean",++ "[].soundboard_allow_pitch": "boolean",++ "[].soundboard_allow_speed": "boolean",++ "[].slur_filter_enabled": "boolean",++ "[].slur_filter_use_builtin": "boolean",++ "[].slur_filter_disabled_categories": "array",++ "[].slur_filter_terms": "array",++ "[].slur_filter_regexes": "array",++ "[].slur_filter_nudge_message": "string",++ "[].min_auto_delete_minutes": "number",++ "[].channel_language": "string"++ },++ "chat": {++ "[].type": "string",++ "[].username": "string",++ "[].core_username": "string",++ "[].user_id": "number",++ "[].anon_id": "null",++ "[].role": "string",++ "[].message": "string",++ "[].stream_id": "number",++ "[].channel_user_id": "number",++ "[].is_global": "boolean",++ "[].avatar_url": "null",++ "[].profile_color": "string",++ "[].timestamp": "string",++ "[].id": "number"++ },++ "coin_earned": {++ "[].type": "string",++ "[].coins": "number",++ "[].total": "number",++ "[].streamerId": "number",++ "[].reason": "string"++ },++ "delete-messages": {++ "[].type": "string",++ "[].ids": "array",++ "[].ids[]": "number"++ },++ "room_joined": {++ "[].type": "string",++ "[].room": "string",++ "[].kind": "string",++ "[].role": "string",++ "[].can": "object",++ "[].can.post": "boolean",++ "[].can.moderate": "boolean",++ "[].can.manage": "boolean",++ "[].can.join": "boolean",++ "[].can.talk": "boolean"++ },++ "room_message": {++ "[].type": "string",++ "[].room": "string",++ "[].message": "object",++ "[].message.id": "number",++ "[].message.user_id": "number",++ "[].message.message": "string",++ "[].message.created_at": "string",++ "[].message.is_deleted": "number",++ "[].message.username": "string",++ "[].message.display_name": "string",++ "[].message.profile_color": "string",++ "[].message.user_role": "string"++ },++ "self-delete-result": {++ "[].type": "string",++ "[].count": "number",++ "[].scope": "string"++ },++ "system": {++ "[].type": "string",++ "[].message": "string",++ "[].timestamp": "string"++ },++ "user-count": {++ "[].type": "string",++ "[].count": "number",++ "[].stream_id": "number"++ },++ "users-list": {++ "[].type": "string",++ "[].users": "object",++ "[].users.logged": "array",++ "[].users.logged[]": "object",++ "[].users.logged[].username": "string",++ "[].users.logged[].display_name": "string",++ "[].users.logged[].avatar_url": "null",++ "[].users.logged[].role": "string",++ "[].users.anonCount": "number"++ }++ }++ }++ ]++ }++}nnew file mode 100644@@@ -0,0 +1,322 @@++{++ "service": "chat",++ "release": "739d3a1cdc22fbb0717e4df4abd6b7e4ad31a8f1",++ "recorded_at": "2026-09-26T14:07:10.965Z",++ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",++ "user_version": 0,++ "schema": [++ "CREATE TABLE audio_requests (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n room TEXT NOT NULL,\n stream_id INTEGER,\n channel_user_id INTEGER,\n kind TEXT NOT NULL CHECK(kind IN ('tts', 'channel-sound', 'soundboard')),\n state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'playing', 'played', 'skipped', 'failed')),\n requested_by TEXT,\n identity_key TEXT,\n label TEXT,\n payload TEXT NOT NULL DEFAULT '{}',\n dedupe_key TEXT,\n attempts INTEGER NOT NULL DEFAULT 0,\n duration_ms INTEGER,\n error TEXT,\n actor TEXT,\n created_at INTEGER NOT NULL,\n started_at INTEGER,\n finished_at INTEGER,\n UNIQUE(room, dedupe_key)\n)",++ "CREATE TABLE bridge_applied (\n key TEXT PRIMARY KEY,\n result TEXT,\n applied_at INTEGER NOT NULL\n)",++ "CREATE TABLE bridge_refs (\n boot TEXT NOT NULL,\n ref INTEGER NOT NULL,\n id INTEGER NOT NULL,\n at INTEGER NOT NULL,\n PRIMARY KEY (boot, ref)\n)",++ "CREATE TABLE calls (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n kind TEXT NOT NULL CHECK(kind IN ('channel', 'direct', 'stream')),\n state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'ringing', 'active', 'ended', 'missed', 'declined', 'failed')),\n channel_id TEXT NOT NULL,\n stream_id INTEGER,\n created_by INTEGER,\n created_by_subject TEXT,\n target_user_id INTEGER,\n target_subject TEXT,\n end_reason TEXT,\n created_at INTEGER NOT NULL,\n started_at INTEGER,\n answered_at INTEGER,\n ended_at INTEGER\n)",++ "CREATE TABLE channel_moderation_settings (\n channel_id INTEGER PRIMARY KEY,\n slow_mode_seconds INTEGER DEFAULT 0,\n followers_only INTEGER DEFAULT 0,\n emote_only INTEGER DEFAULT 0,\n allow_anonymous INTEGER DEFAULT 1,\n links_allowed INTEGER DEFAULT 1,\n gifs_enabled INTEGER DEFAULT 1,\n account_age_gate_hours INTEGER DEFAULT 0,\n caps_percentage_limit INTEGER DEFAULT 0,\n aggressive_filter INTEGER DEFAULT 0,\n max_message_length INTEGER DEFAULT 500,\n slur_filter_enabled INTEGER DEFAULT 0,\n slur_filter_use_builtin INTEGER DEFAULT 1,\n slur_filter_terms TEXT DEFAULT '',\n slur_filter_regexes TEXT DEFAULT '',\n slur_filter_nudge_message TEXT DEFAULT '',\n slur_filter_disabled_categories TEXT DEFAULT '[]',\n ip_approval_mode INTEGER DEFAULT 0,\n soundboard_enabled INTEGER DEFAULT 1,\n soundboard_allow_pitch INTEGER DEFAULT 1,\n soundboard_allow_speed INTEGER DEFAULT 1,\n soundboard_banned_ids TEXT DEFAULT '',\n viewer_auto_delete_enabled INTEGER DEFAULT 1,\n viewer_delete_all_enabled INTEGER DEFAULT 1,\n custom_emotes_enabled INTEGER DEFAULT 1,\n custom_sounds_enabled INTEGER DEFAULT 1,\n max_sound_seconds INTEGER DEFAULT 10,\n uploads_mods_only INTEGER DEFAULT 0,\n mods_can_edit_about INTEGER DEFAULT 0,\n donation_sound_url TEXT,\n donation_sound_mime TEXT,\n goal_sound_url TEXT,\n goal_sound_mime TEXT,\n emote_scale INTEGER DEFAULT 100,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n tts_max_length INTEGER DEFAULT 200,\n sound_min_speed REAL DEFAULT 0.5,\n sound_max_speed REAL DEFAULT 3.0,\n sound_min_pitch_cents INTEGER DEFAULT -1200,\n sound_max_pitch_cents INTEGER DEFAULT 1200,\n emote_size_min INTEGER DEFAULT 50,\n emote_size_max INTEGER DEFAULT 200,\n sounds_mods_only INTEGER DEFAULT 0,\n sub_only INTEGER DEFAULT 0 -- sub-only chat: active subscribers and the room's moderators\n)",++ "CREATE TABLE channel_moderators (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n added_by INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(channel_id, user_id)\n)",++ "CREATE TABLE channel_sounds (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_owner_id INTEGER NOT NULL, -- streamer whose channel this sound belongs to\n command TEXT NOT NULL, -- trigger word (without leading '!'), lowercased\n url TEXT NOT NULL, -- on-disk path of the audio file\n mime TEXT DEFAULT 'audio/mpeg',\n duration_seconds REAL DEFAULT 0,\n created_by INTEGER, -- uploader user id (NULL if removed user)\n created_by_name TEXT DEFAULT '',\n is_approved INTEGER DEFAULT 1,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n emote_code TEXT DEFAULT '',\n created_by_subject_id TEXT\n -- Multiple sounds may share a command; playback picks one at random.\n -- media_url / media_asset_id stay on Live's copy (its asset-sync mirrors files to Media).\n)",++ "CREATE TABLE chat_ai_summaries (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scope TEXT NOT NULL,\n subject_id INTEGER NOT NULL DEFAULT 0,\n window TEXT NOT NULL,\n overview TEXT DEFAULT '',\n memory_json TEXT DEFAULT '',\n timeline_json TEXT DEFAULT '[]',\n message_count INTEGER DEFAULT 0,\n window_message_count INTEGER DEFAULT 0,\n last_message_id INTEGER DEFAULT 0,\n window_label TEXT DEFAULT '',\n window_start DATETIME,\n window_end DATETIME,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(scope, subject_id, window)\n)",++ "CREATE TABLE chat_event_inbox (\n consumer TEXT NOT NULL,\n event_id TEXT NOT NULL,\n processed_at INTEGER NOT NULL,\n PRIMARY KEY (consumer, event_id)\n)",++ "CREATE TABLE chat_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER, -- Live stream (session) id\n user_id INTEGER, -- Live user id; NULL for anon\n anon_id TEXT, -- 'anon12345' format\n username TEXT,\n message TEXT NOT NULL,\n message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')),\n metadata TEXT, -- JSON sidecar for rich events (donation/goal-reached)\n is_global INTEGER DEFAULT 0,\n is_deleted INTEGER DEFAULT 0,\n is_filtered INTEGER DEFAULT 0,\n reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,\n source_platform TEXT,\n deleted_by INTEGER,\n deleted_at DATETIME,\n auto_delete_at DATETIME,\n timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,\n channel_user_id INTEGER, -- the broadcaster whose channel room this is\n subject_id TEXT -- Network subject of the author (usr_…), when known\n)",++ "CREATE TABLE chat_meta (\n key TEXT PRIMARY KEY,\n value TEXT\n)",++ "CREATE TABLE chat_timeline_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scope TEXT NOT NULL DEFAULT 'global',\n subject_id INTEGER NOT NULL DEFAULT 0,\n ts DATETIME NOT NULL,\n label TEXT NOT NULL,\n detail TEXT DEFAULT '',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE ctx_channels (\n id INTEGER PRIMARY KEY,\n user_id INTEGER UNIQUE,\n title TEXT,\n synced_at INTEGER\n)",++ "CREATE TABLE ctx_managed_streams (\n id INTEGER PRIMARY KEY,\n user_id INTEGER,\n slug TEXT,\n title TEXT,\n sort_order INTEGER DEFAULT 0,\n created_at DATETIME,\n synced_at INTEGER\n)",++ "CREATE TABLE ctx_streams (\n id INTEGER PRIMARY KEY,\n user_id INTEGER,\n channel_id INTEGER,\n managed_stream_id INTEGER,\n title TEXT,\n is_live INTEGER DEFAULT 0,\n started_at DATETIME,\n ended_at DATETIME,\n created_at DATETIME,\n synced_at INTEGER\n)",++ "CREATE TABLE ctx_sync (\n key TEXT PRIMARY KEY,\n value TEXT,\n updated_at INTEGER\n)",++ "CREATE TABLE ctx_users (\n id INTEGER PRIMARY KEY,\n username TEXT,\n display_name TEXT,\n avatar_url TEXT,\n profile_color TEXT,\n role TEXT DEFAULT 'user',\n is_banned INTEGER DEFAULT 0,\n ban_reason TEXT,\n is_owner INTEGER DEFAULT 0,\n created_at DATETIME,\n subject_id TEXT,\n synced_at INTEGER\n)",++ "CREATE TABLE deploy_releases (\n head TEXT PRIMARY KEY,\n message_id INTEGER,\n first_via TEXT NOT NULL CHECK(first_via IN ('bridge', 'events')),\n bridge_at INTEGER,\n event_at INTEGER,\n event_id TEXT,\n commit_count INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n)",++ "CREATE TABLE dm_blocks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n blocker_id INTEGER NOT NULL,\n blocked_id INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n blocker_subject_id TEXT,\n UNIQUE(blocker_id, blocked_id)\n)",++ "CREATE TABLE dm_conversations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT,\n is_group INTEGER DEFAULT 0,\n created_by INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE dm_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n conversation_id INTEGER NOT NULL,\n sender_id INTEGER NOT NULL,\n message TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n sender_subject_id TEXT,\n FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE\n)",++ "CREATE TABLE dm_participants (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n conversation_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n last_read_at DATETIME DEFAULT '1970-01-01 00:00:00',\n joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n subject_id TEXT,\n UNIQUE(conversation_id, user_id),\n FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE\n)",++ "CREATE TABLE emotes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n url TEXT NOT NULL,\n animated INTEGER DEFAULT 0,\n width INTEGER DEFAULT 28,\n height INTEGER DEFAULT 28,\n is_global INTEGER DEFAULT 0,\n is_approved INTEGER DEFAULT 1,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n channel_owner_id INTEGER,\n size INTEGER DEFAULT 100,\n media_url TEXT,\n media_asset_id INTEGER\n)",++ "CREATE TABLE events_outbox (\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n event_id TEXT NOT NULL UNIQUE,\n event_type TEXT NOT NULL,\n event TEXT NOT NULL,\n created_at TEXT NOT NULL,\n sent_at TEXT,\n attempts INTEGER DEFAULT 0,\n last_error TEXT\n)",++ "CREATE TABLE hidden_relay_users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id INTEGER,\n platform TEXT NOT NULL,\n external_username TEXT NOT NULL,\n action TEXT DEFAULT 'hide' CHECK(action IN ('hide','ban')),\n reason TEXT,\n created_by INTEGER,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE import_hold (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source_table TEXT NOT NULL,\n source_pk TEXT NOT NULL,\n reason TEXT NOT NULL,\n row_json TEXT NOT NULL,\n held_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(source_table, source_pk)\n)",++ "CREATE TABLE import_runs (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n live_db TEXT,\n dry_run INTEGER DEFAULT 0,\n counts TEXT,\n started_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n finished_at DATETIME\n)",++ "CREATE TABLE live_mirror_outbox (\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n tbl TEXT NOT NULL,\n op TEXT NOT NULL CHECK(op IN ('upsert', 'delete')),\n pk TEXT NOT NULL,\n created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))\n)",++ "CREATE TABLE moderation_actions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scope_type TEXT NOT NULL DEFAULT 'site',\n scope_id INTEGER,\n actor_user_id INTEGER,\n target_user_id INTEGER,\n action_type TEXT NOT NULL,\n details TEXT DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n actor_subject_id TEXT\n)",++ "CREATE TABLE network_blocks (\n blocker_subject TEXT NOT NULL,\n blocked_subject TEXT NOT NULL,\n active INTEGER NOT NULL,\n revision INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n PRIMARY KEY (blocker_subject, blocked_subject)\n )",++ "CREATE TABLE pending_ip_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id INTEGER NOT NULL,\n stream_id INTEGER,\n ip_address TEXT NOT NULL,\n user_id INTEGER,\n anon_id TEXT,\n username TEXT,\n message TEXT NOT NULL,\n status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','denied')),\n reviewed_by INTEGER,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE relay_users (\n platform TEXT NOT NULL,\n username TEXT NOT NULL,\n display_name TEXT,\n first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,\n message_count INTEGER DEFAULT 0,\n PRIMARY KEY (platform, username)\n)",++ "CREATE TABLE room_attachments (\n room_id INTEGER NOT NULL,\n service TEXT NOT NULL,\n resource TEXT NOT NULL,\n title TEXT,\n attached_by INTEGER NOT NULL,\n attached_by_subject TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (room_id, service, resource)\n )",++ "CREATE TABLE room_members (\n room_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n role TEXT NOT NULL DEFAULT 'member',\n last_read_id INTEGER NOT NULL DEFAULT 0,\n joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen_at DATETIME,\n PRIMARY KEY (room_id, user_id)\n )",++ "CREATE TABLE room_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n room_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n subject_id TEXT,\n message TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n is_deleted INTEGER NOT NULL DEFAULT 0,\n deleted_by INTEGER\n )",++ "CREATE TABLE rooms (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT UNIQUE NOT NULL,\n name TEXT NOT NULL,\n topic TEXT,\n kind TEXT NOT NULL DEFAULT 'community',\n visibility TEXT NOT NULL DEFAULT 'public',\n owner_id INTEGER NOT NULL,\n owner_subject TEXT,\n slow_seconds INTEGER NOT NULL DEFAULT 0,\n message_count INTEGER NOT NULL DEFAULT 0,\n last_message_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n archived_at DATETIME\n , join_role TEXT)",++ "CREATE TABLE stream_first_chats (\n chatter_key TEXT NOT NULL,\n channel_user_id INTEGER NOT NULL,\n first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (chatter_key, channel_user_id)\n)",++ "CREATE TABLE table_authority (\n table_name TEXT PRIMARY KEY,\n authority TEXT NOT NULL CHECK(authority IN ('chat', 'live')),\n note TEXT\n)",++ "CREATE TABLE tts_voice_overrides (\n identity_key TEXT PRIMARY KEY,\n voice TEXT,\n pitch INTEGER,\n speed INTEGER,\n gap INTEGER DEFAULT 0,\n set_by INTEGER,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE user_tags (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n tag_id TEXT NOT NULL,\n source TEXT DEFAULT 'shop',\n granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(user_id, tag_id)\n)",++ "CREATE INDEX idx_audio_requests_room_state ON audio_requests(room, state, id)",++ "CREATE INDEX idx_calls_channel_state ON calls(channel_id, state)",++ "CREATE INDEX idx_calls_open ON calls(state) WHERE state IN ('pending', 'ringing', 'active')",++ "CREATE INDEX idx_channel_mods_channel ON channel_moderators(channel_id)",++ "CREATE INDEX idx_channel_mods_user ON channel_moderators(user_id)",++ "CREATE INDEX idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command)",++ "CREATE INDEX idx_channel_sounds_owner ON channel_sounds(channel_owner_id)",++ "CREATE INDEX idx_chat_anon ON chat_messages(anon_id)",++ "CREATE INDEX idx_chat_autodelete ON chat_messages(auto_delete_at)",++ "CREATE INDEX idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp)",++ "CREATE INDEX idx_chat_stream_id ON chat_messages(stream_id)",++ "CREATE INDEX idx_chat_stream_ts ON chat_messages(stream_id, timestamp)",++ "CREATE INDEX idx_chat_timestamp ON chat_messages(timestamp)",++ "CREATE UNIQUE INDEX idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label)",++ "CREATE INDEX idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted)",++ "CREATE INDEX idx_chat_user_id ON chat_messages(user_id)",++ "CREATE INDEX idx_ctx_ms_user ON ctx_managed_streams(user_id)",++ "CREATE INDEX idx_ctx_streams_channel ON ctx_streams(channel_id)",++ "CREATE INDEX idx_ctx_streams_managed ON ctx_streams(managed_stream_id)",++ "CREATE INDEX idx_ctx_streams_user ON ctx_streams(user_id, id)",++ "CREATE INDEX idx_ctx_users_display ON ctx_users(display_name COLLATE NOCASE)",++ "CREATE INDEX idx_ctx_users_subject ON ctx_users(subject_id)",++ "CREATE INDEX idx_ctx_users_username ON ctx_users(username COLLATE NOCASE)",++ "CREATE INDEX idx_dm_blocks_blocked ON dm_blocks(blocked_id)",++ "CREATE INDEX idx_dm_blocks_blocker ON dm_blocks(blocker_id)",++ "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_participants_conv ON dm_participants(conversation_id)",++ "CREATE INDEX idx_dm_participants_user ON dm_participants(user_id)",++ "CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code)",++ "CREATE INDEX idx_emotes_channel_owner ON emotes(channel_owner_id)",++ "CREATE INDEX idx_emotes_user ON emotes(user_id)",++ "CREATE INDEX idx_events_outbox_unsent ON events_outbox(sent_at, seq)",++ "CREATE INDEX idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform)",++ "CREATE INDEX idx_mod_actions_actor ON moderation_actions(actor_user_id)",++ "CREATE INDEX idx_mod_actions_created ON moderation_actions(created_at DESC)",++ "CREATE INDEX idx_mod_actions_scope ON moderation_actions(scope_type, scope_id)",++ "CREATE INDEX idx_network_blocks_blocked ON network_blocks(blocked_subject, active)",++ "CREATE INDEX idx_pending_ip_channel ON pending_ip_messages(channel_id, status)",++ "CREATE INDEX idx_room_members_user ON room_members(user_id)",++ "CREATE INDEX idx_room_messages_room ON room_messages(room_id, id)",++ "CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id)",++ "CREATE INDEX idx_user_tags_user ON user_tags(user_id)"++ ],++ "ledger": {++ "chat_meta": []++ },++ "lazy": [++ "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 )"++ ],++ "statements": [++ "DELETE FROM audio_requests WHERE state IN ('played', 'skipped', 'failed') AND COALESCE(finished_at, created_at) < ?",++ "DELETE FROM bridge_applied WHERE applied_at < ?",++ "DELETE FROM bridge_refs WHERE at < ?",++ "DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?",++ "DELETE FROM channel_sounds WHERE id = ?",++ "DELETE FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?",++ "DELETE FROM dm_messages WHERE id = ?",++ "DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?",++ "DELETE FROM emotes WHERE id = ?",++ "DELETE FROM hidden_relay_users WHERE id = ?",++ "DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS ?",++ "DELETE FROM live_mirror_outbox WHERE seq <= ?",++ "DELETE FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?",++ "DELETE FROM room_members WHERE room_id = ? AND user_id = ?",++ "DELETE FROM tts_voice_overrides WHERE identity_key = ?",++ "DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?",++ "INSERT INTO audio_requests (room, stream_id, channel_user_id, kind, state, requested_by, identity_key, label, payload, dedupe_key, created_at) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)",++ "INSERT INTO bridge_applied (key, result, applied_at) VALUES (?, ?, ?)",++ "INSERT INTO bridge_refs (boot, ref, id, at) VALUES (?, ?, ?, ?) ON CONFLICT(boot, ref) DO UPDATE SET id = excluded.id, at = excluded.at",++ "INSERT INTO calls (kind, state, channel_id, stream_id, created_by, created_by_subject, target_user_id, target_subject, created_at, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO channel_moderation_settings ( channel_id, slow_mode_seconds, followers_only, emote_only, allow_anonymous, links_allowed, gifs_enabled, account_age_gate_hours, caps_percentage_limit, aggressive_filter, max_message_length, slur_filter_enabled, slur_filter_use_builtin, slur_filter_terms, slur_filter_regexes, slur_filter_nudge_message, slur_filter_disabled_categories, ip_approval_mode, soundboard_enabled, soundboard_allow_pitch, soundboard_allow_speed, soundboard_banned_ids, viewer_auto_delete_enabled, viewer_delete_all_enabled, custom_emotes_enabled, custom_sounds_enabled, max_sound_seconds, uploads_mods_only, emote_scale, emote_size_min, emote_size_max, sounds_mods_only, mods_can_edit_about ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO channel_moderation_settings (channel_id) VALUES (?)",++ "INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code, created_by_subject_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO chat_ai_summaries (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 overview = excluded.overview, memory_json = excluded.memory_json, timeline_json = excluded.timeline_json, message_count = excluded.message_count, window_message_count = excluded.window_message_count, last_message_id = excluded.last_message_id, window_label = excluded.window_label, window_start = excluded.window_start, window_end = excluded.window_end, updated_at = CURRENT_TIMESTAMP",++ "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",++ "INSERT INTO ctx_channels (id, user_id, title, synced_at) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET user_id = excluded.user_id, title = excluded.title, synced_at = excluded.synced_at",++ "INSERT INTO ctx_streams (id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET user_id = excluded.user_id, channel_id = excluded.channel_id, managed_stream_id = excluded.managed_stream_id, title = excluded.title, is_live = excluded.is_live, started_at = excluded.started_at, ended_at = excluded.ended_at, created_at = excluded.created_at, synced_at = excluded.synced_at",++ "INSERT INTO ctx_users (id, username, display_name, avatar_url, profile_color, role, is_banned, ban_reason, is_owner, created_at, subject_id, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET username = excluded.username, display_name = excluded.display_name, avatar_url = excluded.avatar_url, profile_color = excluded.profile_color, role = excluded.role, is_banned = excluded.is_banned, ban_reason = excluded.ban_reason, is_owner = excluded.is_owner, created_at = excluded.created_at, subject_id = excluded.subject_id, synced_at = excluded.synced_at",++ "INSERT INTO dm_conversations (name, is_group, created_by) VALUES (?, ?, ?)",++ "INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id) VALUES (?, ?, ?, ?)",++ "INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO events_outbox (event_id, event_type, event, created_at) VALUES (?, ?, ?, ?)",++ "INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details, actor_subject_id) VALUES (?, ?, ?, ?, ?, ?, ?)",++ "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 WHERE excluded.revision > network_blocks.revision",++ "INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, user_id, anon_id, username, message) VALUES (?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1) ON CONFLICT(platform, username) DO UPDATE SET last_seen = CURRENT_TIMESTAMP, message_count = message_count + 1, display_name = excluded.display_name",++ "INSERT INTO room_attachments (room_id, service, resource, title, attached_by, attached_by_subject) VALUES (?, ?, ?, ?, ?, ?)",++ "INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)",++ "INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)",++ "INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', CURRENT_TIMESTAMP)",++ "INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)",++ "INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO table_authority (table_name, authority, note) VALUES (?, 'live', ?) ON CONFLICT(table_name) DO UPDATE SET note = excluded.note",++ "INSERT INTO table_authority (table_name, authority, note) VALUES (?, ?, ?) ON CONFLICT(table_name) DO UPDATE SET authority = excluded.authority, note = excluded.note",++ "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 WHERE excluded.valid_after_ms > token_revocations.valid_after_ms",++ "INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 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",++ "INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)",++ "INSERT OR IGNORE INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?)",++ "INSERT OR IGNORE INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?)",++ "INSERT OR IGNORE INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?)",++ "INSERT OR IGNORE INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)",++ "INSERT OR IGNORE INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)",++ "INSERT OR REPLACE INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by) VALUES (?, ?, ?, ?, ?, ?)",++ "SELECT * FROM audio_requests WHERE id = ?",++ "SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state = 'playing'",++ "SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state IN ('queued', 'playing')",++ "SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1",++ "SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id",++ "SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1",++ "SELECT * FROM audio_requests WHERE room = ? AND state IN ('played', 'skipped', 'failed') ORDER BY COALESCE(finished_at, created_at) DESC, id DESC LIMIT ?",++ "SELECT * FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing') ORDER BY id",++ "SELECT * FROM calls WHERE id = ?",++ "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",++ "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",++ "SELECT * FROM calls WHERE kind IN ('channel', 'stream') AND channel_id = ? AND state = 'active' ORDER BY id DESC LIMIT 1",++ "SELECT * FROM channel_moderation_settings WHERE channel_id = ?",++ "SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?",++ "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 is_approved = 1 ORDER BY command",++ "SELECT * FROM channel_sounds WHERE id = ?",++ "SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?",++ "SELECT * FROM chat_messages WHERE id = ?",++ "SELECT * FROM chat_timeline_events WHERE id = ?",++ "SELECT * FROM ctx_channels WHERE id = ?",++ "SELECT * FROM ctx_channels WHERE user_id = ?",++ "SELECT * FROM ctx_users",++ "SELECT * FROM ctx_users WHERE id = ?",++ "SELECT * FROM ctx_users WHERE subject_id = ? LIMIT 2",++ "SELECT * FROM dm_conversations WHERE id = ?",++ "SELECT * FROM dm_messages WHERE id = ?",++ "SELECT * FROM emotes WHERE id = ?",++ "SELECT * FROM pending_ip_messages WHERE id = ?",++ "SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?",++ "SELECT * FROM room_attachments WHERE room_id = ? ORDER BY created_at, service, resource",++ "SELECT * FROM room_messages WHERE id = ? AND room_id = ?",++ "SELECT * FROM rooms WHERE id = ?",++ "SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL",++ "SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?",++ "SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?",++ "SELECT 1 FROM audio_requests WHERE room = ? AND dedupe_key = ?",++ "SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?",++ "SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?",++ "SELECT 1 FROM chat_messages cm JOIN ctx_streams s ON cm.stream_id = s.id WHERE s.channel_id = ? AND cm.is_deleted = 0 AND (cm.user_id = ? OR cm.anon_id = ?) LIMIT 1",++ "SELECT 1 FROM ctx_streams WHERE id = ?",++ "SELECT 1 FROM ctx_users WHERE id = ?",++ "SELECT 1 FROM dm_blocks WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)",++ "SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?",++ "SELECT 1 FROM dm_messages WHERE conversation_id = ? AND sender_id = ? AND message = ? AND created_at > datetime('now', '-' || ? || ' seconds') LIMIT 1",++ "SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?",++ "SELECT 1 FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND (channel_id = ? OR channel_id IS NULL)",++ "SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1",++ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",++ "SELECT 1 FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?",++ "SELECT COALESCE(MAX(id), 0) AS m FROM ctx_streams",++ "SELECT COALESCE(MAX(id), 0) AS m FROM ctx_users",++ "SELECT COALESCE(SUM(unread), 0) as total FROM ( SELECT COUNT(*) as unread FROM dm_messages m 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 != ? )",++ "SELECT COUNT(*) AS c FROM chat_messages 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)",++ "SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND identity_key = ? AND state IN ('queued', 'playing')",++ "SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing')",++ "SELECT COUNT(*) AS n FROM live_mirror_outbox",++ "SELECT COUNT(*) AS n FROM live_mirror_outbox WHERE tbl = ?",++ "SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'",++ "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 WHERE m.user_id = ? AND m.role != 'blocked'",++ "SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL",++ "SELECT COUNT(*) as c FROM chat_messages WHERE user_id = ? AND is_deleted = 0 AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)",++ "SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?",++ "SELECT COUNT(*) as cnt FROM chat_messages WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0",++ "SELECT COUNT(*) as cnt FROM chat_messages WHERE stream_id = ? AND timestamp >= ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'",++ "SELECT COUNT(*) as cnt FROM chat_messages WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0",++ "SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?",++ "SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?",++ "SELECT DISTINCT room FROM audio_requests WHERE state = 'queued'",++ "SELECT MAX(id) AS id FROM chat_messages",++ "SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?",++ "SELECT MIN(timestamp) AS t FROM chat_messages WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0",++ "SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1",++ "SELECT c.* FROM dm_conversations c 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 = ? WHERE c.is_group = 0",++ "SELECT c.id, c.name, c.is_group, c.updated_at, (SELECT dm.message FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message, (SELECT dm.sender_id FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_sender_id, (SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message_at, (SELECT COUNT(*) FROM dm_messages dm WHERE dm.conversation_id = c.id AND dm.created_at > p.last_read_at AND dm.sender_id != ?) AS unread_count FROM dm_conversations c JOIN dm_participants p ON p.conversation_id = c.id AND p.user_id = ? ORDER BY COALESCE((SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1), c.created_at) DESC",++ "SELECT cm.*, s.title as stream_title FROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id WHERE cm.user_id = ? AND cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?",++ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name FROM chat_messages cm 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' AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)",++ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name FROM chat_messages cm 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' AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) ORDER BY cm.timestamp ASC",++ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, COALESCE(su.username, cu.username) AS stream_channel, s.is_live AS source_is_live, s.managed_stream_id AS source_managed_id, COALESCE(ms.title, s.title) AS source_stream_title, ms.slug AS source_slug FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_users su ON s.user_id = su.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE cm.is_deleted = 0 AND cm.message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation') AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) AND cm.id > ? ORDER BY cm.id ASC LIMIT ?",++ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, COALESCE(su.username, cu.username) AS stream_channel, s.is_live AS source_is_live, s.managed_stream_id AS source_managed_id, COALESCE(ms.title, s.title) AS source_stream_title, ms.slug AS source_slug FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_users su ON s.user_id = su.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE cm.is_deleted = 0 AND cm.message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation') AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) ORDER BY cm.timestamp DESC, cm.id DESC LIMIT ?",++ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, s.title AS source_stream_title, s.managed_stream_id AS source_managed_id, s.is_live AS source_is_live, ms.slug AS source_slug, COALESCE(bu.username, cu.username) AS source_channel FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id LEFT JOIN ctx_users bu ON s.user_id = bu.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id 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)",++ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, s.title AS source_stream_title, s.managed_stream_id AS source_managed_id, s.is_live AS source_is_live, ms.slug AS source_slug, COALESCE(bu.username, cu.username) AS source_channel FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id LEFT JOIN ctx_users bu ON s.user_id = bu.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id 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) ORDER BY cm.timestamp DESC LIMIT ?",++ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, s.title AS source_stream_title, s.managed_stream_id AS source_managed_id, s.is_live AS source_is_live, ms.slug AS source_slug, COALESCE(bu.username, cu.username) AS source_channel FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id LEFT JOIN ctx_users bu ON s.user_id = bu.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id 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) ORDER BY cm.timestamp DESC, cm.id DESC LIMIT ?",++ "SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id WHERE cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)",++ "SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id WHERE cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) AND cm.user_id = ?",++ "SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id WHERE cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) AND cm.user_id = ? ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?",++ "SELECT created_at FROM ctx_users WHERE id = ?",++ "SELECT created_at FROM room_messages WHERE room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1",++ "SELECT id FROM bridge_refs WHERE boot = ? AND ref = ?",++ "SELECT id FROM calls WHERE channel_id = ? AND state = 'active'",++ "SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND state IN ('pending', 'ringing')",++ "SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND target_user_id = ? AND state = 'ringing'",++ "SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?",++ "SELECT id FROM chat_messages WHERE anon_id = ? AND stream_id = ? AND is_deleted = 0",++ "SELECT id FROM chat_messages WHERE user_id = ? AND stream_id = ? AND is_deleted = 0",++ "SELECT id FROM ctx_streams WHERE is_live = 1",++ "SELECT id FROM ctx_streams WHERE user_id = ? ORDER BY id DESC LIMIT 1",++ "SELECT id FROM ctx_users WHERE subject_id = ?",++ "SELECT id FROM emotes WHERE id = ?",++ "SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id",++ "SELECT id FROM user_tags WHERE user_id = ? AND tag_id = ?",++ "SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1",++ "SELECT id, state FROM calls WHERE state IN ('pending', 'ringing', 'active')",++ "SELECT id, stream_id FROM chat_messages WHERE is_deleted = 0 AND auto_delete_at IS NOT NULL AND auto_delete_at <= CURRENT_TIMESTAMP ORDER BY auto_delete_at ASC LIMIT ?",++ "SELECT id, username, display_name, avatar_url, profile_color FROM ctx_users WHERE id != ? AND is_banned = 0 AND id NOT IN (SELECT blocked_id FROM dm_blocks WHERE blocker_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 blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1) AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE) LIMIT ?",++ "SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_messages m JOIN ctx_users u ON u.id = m.sender_id WHERE m.conversation_id = ? AND m.id < ? ORDER BY m.created_at DESC LIMIT ?",++ "SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_messages m JOIN ctx_users u ON u.id = m.sender_id WHERE m.conversation_id = ? AND m.id > ? ORDER BY m.created_at ASC LIMIT ?",++ "SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_messages m JOIN ctx_users u ON u.id = m.sender_id WHERE m.conversation_id = ? ORDER BY m.created_at DESC LIMIT ?",++ "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",++ "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 WHERE m.id = ?",++ "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 WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?",++ "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 WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?",++ "SELECT message_id FROM deploy_releases WHERE head = ?",++ "SELECT metadata FROM chat_messages WHERE id = ?",++ "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 FROM ctx_managed_streams ms WHERE ms.user_id = ? ORDER BY ms.sort_order ASC, ms.created_at ASC",++ "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 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 ORDER BY COALESCE(r.last_message_at, r.created_at) DESC",++ "SELECT result FROM bridge_applied WHERE key = ?",++ "SELECT role FROM room_members WHERE room_id = ? AND user_id = ?",++ "SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?",++ "SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color, ms.slug AS managed_stream_slug, ms.title AS managed_stream_title FROM ctx_streams s LEFT JOIN ctx_users u ON s.user_id = u.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id",++ "SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color, ms.slug AS managed_stream_slug, ms.title AS managed_stream_title FROM ctx_streams s LEFT JOIN ctx_users u ON s.user_id = u.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE s.id = ?",++ "SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color, ms.slug AS managed_stream_slug, ms.title AS managed_stream_title FROM ctx_streams s LEFT JOIN ctx_users u ON s.user_id = u.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?",++ "SELECT seq, event_id, event FROM events_outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100",++ "SELECT seq, tbl, op, pk FROM live_mirror_outbox ORDER BY seq LIMIT ?",++ "SELECT subject_id FROM ctx_users WHERE id = ?",++ "SELECT table_name, authority FROM table_authority",++ "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",++ "SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_participants p JOIN ctx_users u ON u.id = p.user_id WHERE p.conversation_id = ?",++ "SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, b.created_at as blocked_at FROM dm_blocks b JOIN ctx_users u ON u.id = b.blocked_id WHERE b.blocker_id = ? ORDER BY b.created_at DESC",++ "SELECT user_id, subject_id FROM dm_participants WHERE conversation_id = ?",++ "SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?",++ "SELECT value FROM chat_meta WHERE key = ?",++ "SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?",++ "UPDATE audio_requests SET attempts = attempts + 1 WHERE id = ?",++ "UPDATE audio_requests SET state = 'failed', finished_at = ?, error = 'expired', actor = 'system' WHERE state = 'queued' AND created_at < ?",++ "UPDATE audio_requests SET state = 'played', finished_at = ?, error = 'delivered before a restart' WHERE state = 'playing'",++ "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, finished_at = CASE WHEN ? THEN ? ELSE finished_at END WHERE id = ? AND state IN (?,?)",++ "UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?",++ "UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?",++ "UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?",++ "UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?",++ "UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?",++ "UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (?)",++ "UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?",++ "UPDATE chat_messages SET metadata = ? WHERE id = ?",++ "UPDATE chat_messages SET username = ? WHERE user_id = ?",++ "UPDATE ctx_streams SET is_live = 0 WHERE id = ?",++ "UPDATE ctx_users SET profile_color = ? WHERE id = ?",++ "UPDATE dm_conversations SET is_group = 1 WHERE id = ?",++ "UPDATE dm_conversations SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE dm_conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE dm_participants SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = ? AND user_id = ?",++ "UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?",++ "UPDATE events_outbox SET attempts = attempts + 1, last_error = ? WHERE seq = ?",++ "UPDATE events_outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?",++ "UPDATE pending_ip_messages SET status = 'approved', 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'",++ "UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?",++ "UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?",++ "UPDATE room_attachments SET title = ? WHERE room_id = ? AND service = ? AND resource = ?",++ "UPDATE room_members SET last_read_id = ?, last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?",++ "UPDATE room_members SET last_read_id = MAX(last_read_id, ?), last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?",++ "UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?",++ "UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?",++ "UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?",++ "UPDATE table_authority SET authority = ? WHERE table_name = ?"++ ]++}nnew file mode 100644@@@ -0,0 +1,142 @@++'use strict';++/**++ * N-1 (roadmap WS-P task 11, ADR-016): during a deploy, and for the 24-hour mixed-version window after++ * it, open tabs run the previous release's client against this release's server, and a previous-release++ * process can still be working on the database this release migrated. Both must keep working.++ *++ * - An N-1 client against the N server: test/fixtures/n-1/client.json is every call the previous++ * release's client code makes (found statically, made concrete with seeded values) with what that++ * release answered: status, JSON or not, and the response fields the old client reads with their++ * types. This checkout boots (test/n-1/service.js) and must answer each call compatibly: any 2xx++ * for a 2xx, the same status otherwise (a 5xx only needs the route), JSON where there was JSON,++ * every read field still there with a type it had. The run sits inside openvibe-shared's++ * mixed-version matrix (release-compat assertMixedVersion) with both releases' manifests.++ * - N-1 SQL against the N schema: test/fixtures/n-1/worker.json holds the previous release's schema++ * (and migration ledger, and what it creates on first use) and every statement it ran or has as a++ * literal. The database this test boots on is created from that schema, so this release's++ * migrations run over it; afterwards every N-1 statement must still prepare (no table or column it++ * names is gone), and no N-1 INSERT may miss a column this release made NOT NULL without a default.++ *++ * The fixtures come from the release in production: `npm run n-1:record` after each deploy (see++ * scripts/n-1-record.js). This test needs no git history and no network.++ *++ * node test/n-1.test.js++ */++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');++++const ROOT = path.join(__dirname, '..');++const FIX = path.join(__dirname, 'fixtures', 'n-1');++const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));++const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${String(err.message).split('\n').join('\n ')}`); }++}++++(async () => {++ const age = Math.round((Date.now() - Date.parse(client.recorded_at)) / 86400000);++ console.log(`n-1: ${svc.service} N-1 = ${client.release.slice(0, 12)}, recorded ${client.recorded_at.slice(0, 10)} (${age} d ago); ${client.calls.length} calls, ${worker.statements.length} statements`);++++ console.log('n-1: the harness');++ await check('finds call sites, their method and template, and skips computed paths', () => {++ const files = [{ name: 'a.js', text: "api('/streams'); api(`/users/${encodeURIComponent(u.username)}/follow`, { method: 'POST', body: {} }); fetch(`${API}/api/x?limit=${n}`); api(path); fetch('https://elsewhere/x'); api(`/b/${on ? 'mute' : 'unmute'}`, { method: 'post' });" }];++ const { calls, dynamic } = h.extractCalls({ files, callers: [{ name: 'api', prefix: '/api' }, { name: 'fetch' }], strip: ['API'] });++ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.template}`), ['POST /api/b/{on ? \'mute\' : \'unmute\'}', 'GET /api/streams', 'POST /api/users/{encodeURIComponent(u.username)}/follow', 'GET /api/x?limit={n}']);++ assert.strictEqual(dynamic, 1);++ const samples = [[/id\)*$/i, '1'], [/name\)*$/i, 'n1star']];++ assert.deepStrictEqual(calls.map((c) => h.concretePath(c.parts, samples)), ['/api/b/mute', '/api/streams', '/api/users/n1star/follow', '/api/x?limit=']);++ });++ await check('reads: the key paths the client names; a gone or retyped one is a problem, an empty list or null parent is not', () => {++ const ids = h.clientIdentifiers(['x.streams.map((s) => s.title); const { total } = d; s?.viewer_count']);++ const reads = h.readsOf({ streams: [{ title: 'a', viewer_count: 1, secret: 'k' }], total: 3, other: 1 }, ids);++ assert.deepStrictEqual(reads, { streams: 'array', 'streams[]': 'object', 'streams[].title': 'string', 'streams[].viewer_count': 'number', total: 'number' });++ assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: 2 }], total: 1 }), []);++ assert.deepStrictEqual(h.readProblems(reads, { streams: [], total: 0 }), []);++ 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();++ });++++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-test-'));++ const dbPath = path.join(tmp, 'db', `${svc.service}.db`);++ 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]));++ }++ }++ for (const ddl of worker.lazy || []) d.exec(ddl);++ })();++ d.pragma(`user_version = ${Number(worker.user_version) || 0}`);++ d.close();++ svc.seed({ dir: ROOT, dbPath, dataDir });++ });++ server = await svc.boot({ dir: ROOT, dbPath, dataDir });++ const manifest = await (await fetch(`${server.url}/release.json`)).json();++++ console.log('n-1: the N-1 client against this server');++ const sockets = client.ws ? `, and ${client.ws.sends.length} socket message types in ${client.ws.sessions.length} sessions` : '';++ await check(`every call the N-1 client makes is answered compatibly (${client.calls.length} requests${sockets}, in openvibe-shared's mixed-version matrix)`, async () => {++ const broken = [];++ const n1Client = async (url) => {++ const probe = await h.notFoundProbe(url);++ for (const rec of client.calls) {++ const got = await h.send(url, { method: rec.method, path: h.fillPath(rec.path, server.ids), headers: { ...(rec.accept ? { accept: 'text/html' } : {}), ...server.headers(rec.auth) }, body: rec.method === 'GET' ? undefined : {} });++ const problems = h.callProblems(rec, got, probe);++ if (problems.length) broken.push(`${rec.auth} ${rec.method} ${rec.path} (${rec.from.join(', ')}): ${problems.join('; ')}`);++ }++ for (const session of (client.ws && client.ws.sessions) || []) {++ const messages = h.wsMessages(client.ws.sends, { order: svc.ws.order, values: server.wsValues(session.auth) });++ const received = await h.wsSession({ url: url.replace(/^http/, 'ws') + client.ws.path, headers: svc.ws.headers, messages });++ broken.push(...h.wsProblems(session.received, received).map((p) => `socket ${client.ws.path} (${session.auth}): ${p}`));++ }++ if (broken.length) throw new Error(`${broken.length} broken:\n${h.summarize(broken)}`);++ };++ await compat.assertMixedVersion({ releases: [++ { name: 'N-1', manifest: client.manifest, client: n1Client },++ { name: 'N', manifest, server: async () => ({ url: server.url }) },++ ] });++ });++ await server.close();++ 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();++ 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 {++ if (server) { console.log(server.log().slice(-1500)); await server.close(); }++ fs.rmSync(tmp, { recursive: true, force: true });++ }++++ if (failures) { console.log(`\nn-1: ${failures} check(s) failed`); process.exit(1); }++ console.log('\nn-1: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,36 @@++'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,++ * and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it (Chat's own shutdown).++ */++const path = require('path');++++(async () => {++ const dir = process.cwd();++ 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 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 });++ 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');++ } 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);++ conversation = c && (c.id || c.conversation_id || c);++ 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,855 @@++'use strict';++/**++ * N-1 harness (roadmap WS-P task 11, ADR-016's mixed-version window): the previous release's client++ * against this release's server, and the previous release's SQL against this release's schema.++ * The same file in Live, Chat, Media and Community; each repository's test/n-1/service.js says what++ * its clients are, how to boot and seed it, and where its SQL lives.++ *++ * scripts/n-1-record.js <ref> at release time: checks out <ref> (the release in production, which++ * is N-1 for the next deploy) into a temporary worktree, boots it, and++ * writes test/fixtures/n-1/{client,worker}.json++ * test/n-1.test.js in npm test and CI: boots this checkout on a database created with++ * N-1's schema and replays the fixture; no git history, no network++ *++ * Client calls are found statically in the N-1 client code (the call sites of `api(...)`, `fetch(...)`++ * and the service's other wrappers, with a literal or template path), made concrete with sample values,++ * and replayed against the booted N-1 server: every call anonymously, reads again signed in. What the++ * fixture keeps per call is what an old client depends on: the status, whether the answer is JSON,++ * and the response fields it reads (the JSON key paths whose every key the N-1 client code names, with++ * their types).++ *++ * SQL is every statement N-1 prepared while it served those calls, plus every literal SQL string in its++ * server code that prepares on its own schema. The N-1 schema is its sqlite_master after the replay.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const { execFileSync } = require('child_process');++++// ── Client calls: static extraction ──────────────────────────++++/** Reads a JS string/template literal at src[i] (a quote). → { end, parts: [{ text } | { expr }] } or null. */++function readLiteral(src, i) {++ const q = src[i];++ if (q !== '\'' && q !== '"' && q !== '`') return null;++ const parts = [];++ let text = '';++ let j = i + 1;++ while (j < src.length) {++ const c = src[j];++ if (c === '\\') { text += unescapeChar(src[j + 1]); j += 2; continue; }++ if (c === q) { if (text) parts.push({ text }); return { end: j + 1, parts }; }++ if (q !== '`' && c === '\n') return null;++ if (q === '`' && c === '$' && src[j + 1] === '{') {++ if (text) parts.push({ text });++ text = '';++ const close = skipBalanced(src, j + 1, '{', '}');++ if (close < 0) return null;++ parts.push({ expr: src.slice(j + 2, close).trim() });++ j = close + 1;++ continue;++ }++ text += c;++ j++;++ }++ return null;++}++++function unescapeChar(c) {++ return { n: '\n', t: '\t', r: '\r', '0': '\0' }[c] ?? c ?? '';++}++++/** Index of the bracket closing the one at src[i] (open), skipping strings and templates; -1 if none. */++function skipBalanced(src, i, open, close) {++ let depth = 0;++ for (let j = i; j < src.length; j++) {++ const c = src[j];++ if (c === '\'' || c === '"' || c === '`') {++ const lit = readLiteral(src, j);++ if (lit) { j = lit.end - 1; continue; }++ }++ if (c === open) depth++;++ else if (c === close) { depth--; if (depth === 0) return j; }++ }++ return -1;++}++++/** Top-level comma-separated arguments of a call whose '(' is at src[i]. */++function callArgs(src, i) {++ const end = skipBalanced(src, i, '(', ')');++ if (end < 0) return null;++ const args = [];++ let depth = 0; let start = i + 1;++ for (let j = i + 1; j < end; j++) {++ const c = src[j];++ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }++ if (c === '(' || c === '{' || c === '[') depth++;++ else if (c === ')' || c === '}' || c === ']') depth--;++ else if (c === ',' && depth === 0) { args.push(src.slice(start, j).trim()); start = j + 1; }++ }++ const last = src.slice(start, end).trim();++ if (last) args.push(last);++ return args;++}++++const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');++++/** src split at every top-level `sep` (outside strings and brackets). */++function splitTop(src, sep) {++ const out = [];++ let depth = 0; let start = 0;++ for (let j = 0; j < src.length; j++) {++ const c = src[j];++ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }++ if (c === '(' || c === '{' || c === '[') depth++;++ else if (c === ')' || c === '}' || c === ']') depth--;++ else if (c === sep && depth === 0) { out.push(src.slice(start, j)); start = j + 1; }++ }++ out.push(src.slice(start));++ return out.map((x) => x.trim());++}++++/**++ * The parts of a path expression: a literal, a template, or literals, templates and names joined with++ * `+` ('/api/x/' + id + '?a=1'); a name in `vars` stands for the path it was assigned. → parts or null++ */++function pathParts(expr, vars = new Map()) {++ const parts = [];++ for (const piece of splitTop(expr, '+')) {++ if (!piece) return null;++ const lit = /^['"`]/.test(piece) ? readLiteral(piece, 0) : null;++ if (lit && lit.end === piece.length) parts.push(...lit.parts.flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x])));++ else if (vars.has(piece)) parts.push(...vars.get(piece));++ else parts.push({ expr: piece });++ }++ const merged = [];++ for (const p of parts) {++ const last = merged[merged.length - 1];++ if (p.text != null && last && last.text != null) last.text += p.text; else merged.push({ ...p });++ }++ return merged;++}++++/** `esc(base)`, `encodeURIComponent(base)`: the name inside one escaping call. */++function unwrap(expr) {++ const m = /^(?:esc|enc|encodeURI|encodeURIComponent|String|escapeHtml|escHtml)\(\s*([A-Za-z_$][\w$]*)\s*\)$/.exec(String(expr).trim());++ return m ? m[1] : String(expr).trim();++}++++/** `var|let|const name = <path expression>` in a file, for names later calls use as a path base. */++function pathVars(src) {++ const vars = new Map();++ for (const m of src.matchAll(/(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*(?=['"`])/g)) {++ let depth = 0; let end = src.length;++ for (let j = m.index + m[0].length; j < src.length; j++) {++ const c = src[j];++ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }++ if (c === '(' || c === '{' || c === '[') depth++;++ else if (c === ')' || c === '}' || c === ']') { if (depth === 0) { end = j; break; } depth--; } else if (depth === 0 && (c === ';' || c === '\n' || c === ',')) { end = j; break; }++ }++ const parts = pathParts(src.slice(m.index + m[0].length, end).trim(), vars);++ if (parts && parts[0] && parts[0].text && parts[0].text.startsWith('/') && !parts[0].text.startsWith('//')) vars.set(m[1], parts);++ }++ return vars;++}++++/**++ * Every call site of the service's request functions whose path is a literal, a template or a++ * concatenation starting with one (or with a variable assigned one).++ * callers: [{ name: 'api', prefix: '/api', method?: 'POST', methodArg?: 1 }]++ * strip: template expressions dropped when a path starts with them (e.g. 'API', 'location.origin')++ * keep(pathname, method, file): whether a call is this server's++ * → { calls: [{ method, template, parts, from: [file] }], dynamic: calls with a computed path }++ */++function extractCalls({ files, callers, strip = [], keep = (p) => true, forms = false }) {++ const found = new Map();++ let dynamic = 0;++ for (const { name: file, text: src } of files) {++ const vars = pathVars(src);++ for (const caller of callers) {++ const re = new RegExp(`(?<![\\w$.])${esc(caller.name)}\\s*\\(`, 'g');++ let m;++ while ((m = re.exec(src))) {++ const open = m.index + m[0].length - 1;++ const args = callArgs(src, open);++ if (!args || !args.length) continue;++ // An SDK-style call: call({ method: 'POST', path: `/api/x/${id}`, … }).++ let pathExpr = args[0];++ let objMethod = null;++ if (caller.object) {++ if (!/^\{[\s\S]*\}$/.test(args[0])) continue; // some other function of that name++ const entries = splitTop(args[0].slice(1, -1), ',');++ const entry = (k) => { const e = entries.find((x) => new RegExp(`^${k}\\s*:`).test(x)); return e ? e.replace(new RegExp(`^${k}\\s*:\\s*`), '') : null; };++ pathExpr = entry('path');++ const me = entry('method');++ const ml = me && /^['"`]/.test(me) ? readLiteral(me, 0) : null;++ if (ml && ml.parts.length === 1 && ml.parts[0].text) objMethod = ml.parts[0].text;++ if (!pathExpr) continue;++ }++ let parts = pathParts(pathExpr, vars);++ if (!parts) { dynamic++; continue; }++ while (parts.length && parts[0].expr && strip.includes(parts[0].expr)) parts.shift();++ if (!parts.length || !parts[0].text || !parts[0].text.startsWith('/') || parts[0].text.startsWith('//')) {++ if (parts.length && parts[0].expr) dynamic++;++ continue;++ }++ parts = caller.prefix ? [{ text: caller.prefix }, ...parts] : parts;++ if (parts[1] && parts[0].text != null && parts[1].text != null) parts = [{ text: parts[0].text + parts[1].text }, ...parts.slice(2)];++ // `/api${path}`: a wrapper passing a path through, not a call.++ if (parts.some((p, k) => p.expr && k > 0 && parts[k - 1].text != null && !parts.slice(0, k).some((x) => x.text && x.text.includes('?')) && /[A-Za-z0-9]$/.test(parts[k - 1].text))) { dynamic++; continue; }++ const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('').replace(/^([^?#]*)\/\//, '$1/');++ let method = objMethod || caller.method || 'GET';++ if (caller.object) { /* the method was in the object */ } else if (caller.methodArg != null) {++ const a = args[caller.methodArg];++ const ml = a && /^['"`]/.test(a) ? readLiteral(a, 0) : null;++ if (ml && ml.parts.length === 1 && ml.parts[0].text) method = ml.parts[0].text;++ } else {++ const opts = args.slice(1).join(',');++ const mm = /\bmethod\s*:\s*['"`]([A-Za-z]+)['"`]/.exec(opts);++ if (mm) method = mm[1];++ }++ method = method.toUpperCase();++ if (!keep(template.split('?')[0], method, file)) continue;++ const key = `${method} ${template}`;++ if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });++ found.get(key).from.add(file);++ }++ }++ }++ // Forms the pages post (or get) to: <form action="/x/${id}" method="post"> in markup or a template.++ if (forms) {++ for (const { name: file, text: src } of files) {++ const vars = pathVars(src);++ for (const m of src.matchAll(/<form\b/gi)) {++ const end = tagEnd(src, m.index);++ if (end < 0) continue;++ const tag = src.slice(m.index, end);++ const action = attr(tag, 'action');++ if (!action || action.startsWith('//') || /^[a-z]+:/i.test(action)) continue;++ const parts = templateParts(action).flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x]));++ if (!parts.length || parts[0].text == null || !parts[0].text.startsWith('/')) continue;++ const methodAttr = attr(tag, 'method');++ const method = /^post$/i.test(methodAttr || '') ? 'POST' : 'GET';++ const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('');++ if (!keep(template.split('?')[0], method, file)) continue;++ const key = `${method} ${template}`;++ if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });++ found.get(key).from.add(file);++ }++ }++ }++ const calls = [...found.values()].map((c) => ({ ...c, from: [...c.from].sort() }))++ .sort((a, b) => (a.template < b.template ? -1 : a.template > b.template ? 1 : a.method < b.method ? -1 : 1));++ return { calls, dynamic };++}++++/** Where an HTML tag that starts at src[i] ends ('>' outside quotes and ${…}); -1 if it does not. */++function tagEnd(src, i) {++ let q = null;++ for (let j = i; j < src.length; j++) {++ const c = src[j];++ if (c === '$' && src[j + 1] === '{') { const close = skipBalanced(src, j + 1, '{', '}'); if (close < 0) return -1; j = close; continue; }++ if (q) { if (c === q) q = null; continue; }++ if (c === '"' || c === '\'') q = c;++ else if (c === '>') return j + 1;++ }++ return -1;++}++++/** An attribute's raw value in a tag (quoted, may hold ${…}). */++function attr(tag, name) {++ const m = new RegExp(`\\s${name}\\s*=\\s*(["'])`, 'i').exec(tag);++ if (!m) return null;++ const q = m[1];++ let out = '';++ for (let j = m.index + m[0].length; j < tag.length; j++) {++ const c = tag[j];++ if (c === '$' && tag[j + 1] === '{') { const close = skipBalanced(tag, j + 1, '{', '}'); if (close < 0) return null; out += tag.slice(j, close + 1); j = close; continue; }++ if (c === q) return out;++ out += c;++ }++ return null;++}++++/** Text with ${expr} holes → parts. */++function templateParts(text) {++ const parts = [];++ let buf = '';++ for (let j = 0; j < text.length; j++) {++ if (text[j] === '$' && text[j + 1] === '{') {++ const close = skipBalanced(text, j + 1, '{', '}');++ if (close < 0) break;++ if (buf) parts.push({ text: buf });++ buf = '';++ parts.push({ expr: text.slice(j + 2, close).trim() });++ j = close;++ continue;++ }++ buf += text[j];++ }++ if (buf) parts.push({ text: buf.replace(/&/g, '&') });++ return parts;++}++++/**++ * A concrete path for a template: each ${expr} becomes the first sample whose pattern matches the++ * expression text, else '1' in the path and '' in the query.++ */++function concretePath(parts, samples = []) {++ let out = '';++ for (const p of parts) {++ if (p.text != null) { out += p.text; continue; }++ const inQuery = out.includes('?');++ // `${on ? 'mute' : 'unmute'}`: the first branch.++ const branch = /\?\s*(['"`])([^'"`]*)\1\s*:/.exec(p.expr);++ if (branch) { out += branch[2]; continue; }++ const hit = samples.find(([re]) => re.test(p.expr));++ let v = hit ? hit[1] : (inQuery ? '' : '1');++ if (typeof v === 'function') v = v(p.expr, inQuery);++ // '@name': a value the booted server seeded (a random slug); filled in when the call is sent.++ if (/^@\w+$/.test(String(v))) { out += `{${v}}`; continue; }++ out += inQuery ? String(v) : encodeURIComponent(String(v)).replace(/%2F/gi, '/');++ }++ out = out.replace(/^([^?#]*)\/\/+/, '$1/').replace(/[?&]+$/, '').replace(/\?&+/, '?');++ return out;++}++++/**++ * The same-origin URLs a served page makes the browser fetch or offers to follow: src, href, poster,++ * data-src and form actions (with their method). `origins` are absolute origins that count as this++ * site (e.g. its public URL). → [{ method, path }]++ */++function pageLinks(html, origins = []) {++ const out = new Map();++ const add = (method, raw) => {++ let u = String(raw || '').trim().replace(/&/g, '&');++ if (!u || /^(#|mailto:|javascript:|data:|tel:|blob:)/i.test(u)) return;++ const o = origins.find((x) => u.startsWith(`${x}/`) || u === x);++ if (o) u = u.slice(o.length) || '/';++ if (!u.startsWith('/') || u.startsWith('//')) return;++ u = u.split('#')[0];++ out.set(`${method} ${u}`, { method, path: u });++ };++ for (const m of String(html).matchAll(/<([a-zA-Z][\w-]*)\b([^>]*)>/g)) {++ const tag = m[1].toLowerCase(); const attrs = m[2];++ const get = (n) => { const a = new RegExp(`\\s${n}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs); return a ? (a[1] != null ? a[1] : a[2]) : null; };++ if (tag === 'form') { const a = get('action'); if (a != null) add(/^post$/i.test(get('method') || '') ? 'POST' : 'GET', a); continue; }++ for (const n of ['src', 'href', 'poster', 'data-src']) { const v = get(n); if (v != null) add('GET', v); }++ }++ return [...out.values()];++}++++/** A path with the seeded ids that differ per run (random slugs, keys) put back as {@name} slots. */++function unfillPath(p, ids = {}) {++ let out = String(p);++ for (const [name, v] of Object.entries(ids || {})) {++ const s = v == null ? '' : String(v);++ if (s.length < 6 || /^\d+$/.test(s)) continue;++ out = out.split(encodeURIComponent(s)).join(`{@${name}}`).split(s).join(`{@${name}}`);++ }++ return out;++}++++/** A recorded path with its {@name} slots filled from the booted server's seeded ids. */++function fillPath(p, ids = {}) {++ return String(p).replace(/\{@(\w+)\}/g, (m, name) => encodeURIComponent(ids[name] == null ? '0' : String(ids[name])));++}++++/** Property names the client code reads: `.name`, `?.name`, `['name']`, 'name' literals, destructured names. */++function clientIdentifiers(texts) {++ const ids = new Set();++ for (const t of texts) {++ for (const m of t.matchAll(/\??\.\s*([A-Za-z_$][\w$]*)/g)) ids.add(m[1]);++ for (const m of t.matchAll(/['"`]([A-Za-z_$][\w$]*)['"`]/g)) ids.add(m[1]);++ for (const m of t.matchAll(/\{([^{}()]*)\}\s*(?:=[^=>]|\)\s*=>|\s*\))/g)) {++ for (const part of m[1].split(',')) {++ const id = /^\s*(?:\.\.\.)?\s*([A-Za-z_$][\w$]*)/.exec(part);++ if (id) ids.add(id[1]);++ }++ }++ }++ return ids;++}++++// ── Response shapes ──────────────────────────────────────────++++function typeOf(v) {++ if (v === null) return 'null';++ if (Array.isArray(v)) return 'array';++ return typeof v;++}++++/**++ * The key paths of a JSON value → { 'a': 'object', 'a.b': 'string', 'list[]': 'object', 'list[].x': 'number' }.++ * Array elements are merged: a path is present when any element has it; its types are all it takes.++ */++function flatten(value, maxDepth = 5) {++ const out = {};++ const add = (p, t) => { if (!out[p]) out[p] = new Set(); out[p].add(t); };++ const walk = (v, prefix, depth) => {++ if (depth > maxDepth) return;++ if (Array.isArray(v)) {++ for (const el of v) { add(`${prefix}[]`, typeOf(el)); walk(el, `${prefix}[]`, depth + 1); }++ } else if (v && typeof v === 'object') {++ for (const [k, x] of Object.entries(v)) {++ const p = prefix ? `${prefix}.${k}` : k;++ add(p, typeOf(x));++ walk(x, p, depth + 1);++ }++ }++ };++ walk(value, '', 0);++ return out;++}++++/** The key paths of `value` an old client reads: every key along the path is one the client names. */++function readsOf(value, ids) {++ const flat = flatten(value);++ const reads = {};++ for (const [p, types] of Object.entries(flat)) {++ const keys = p.split('.').map((k) => k.replace(/\[\]$/g, '')).filter(Boolean);++ if (!keys.length || !keys.every((k) => ids.has(k))) continue;++ const t = [...types].filter((x) => x !== 'null').sort();++ reads[p] = t.length ? t.join('|') : 'null';++ }++ return reads;++}++++/** What `reads` (recorded from N-1) finds missing or retyped in `value` (from N). */++function readProblems(reads, value) {++ const flat = flatten(value);++ const problems = [];++ // A path under an array N answers empty (or null) cannot be read by the old client either.++ const emptyArray = (p) => {++ const segs = p.split('.');++ for (let i = 1; i <= segs.length; i++) {++ const head = segs.slice(0, i).join('.');++ if (!head.endsWith('[]')) continue;++ const arr = head.slice(0, -2);++ if (!flat[head] && (arr === '' ? Array.isArray(value) : flat[arr] && !flat[arr].has('object'))) return true;++ }++ return false;++ };++ const nullParent = (p) => {++ const segs = p.split('.');++ for (let i = 1; i < segs.length; i++) {++ const head = segs.slice(0, i).join('.').replace(/\[\]$/, '');++ if (flat[head] && [...flat[head]].every((t) => t === 'null')) return true;++ }++ return false;++ };++ for (const [p, want] of Object.entries(reads)) {++ if (emptyArray(p) || nullParent(p)) continue;++ const have = flat[p];++ if (!have) { problems.push(`${p} is gone`); continue; }++ if (want === 'null') continue;++ const now = [...have].filter((t) => t !== 'null');++ if (!now.length) continue;++ const was = want.split('|');++ if (!now.some((t) => was.includes(t))) problems.push(`${p} was ${want}, now ${now.join('|')}`);++ }++ return problems;++}++++/** Whether N's status keeps N-1's promise: any 2xx for a 2xx, the same code otherwise; a 5xx only needs the route. */++function statusCompatible(was, now) {++ if (was >= 200 && was < 300) return now >= 200 && now < 300;++ if (was >= 500) return now !== 404 && now !== 405;++ return was === now;++}++++// ── Replaying calls ──────────────────────────────────────────++++function kindOf(contentType, text) {++ const ct = String(contentType || '').toLowerCase();++ if (ct.includes('text/event-stream')) return 'sse';++ if (ct.includes('json')) return 'json';++ if (ct.includes('html')) return 'html';++ if (!text) return 'empty';++ if (ct.startsWith('text/')) return 'text';++ return 'other';++}++++/**++ * Sends one call. → { status, kind, json, location }. Redirects are not followed (they may leave the++ * sandbox); an event stream is not read past its headers.++ */++async function send(baseUrl, { method, path: p, headers = {}, body }, { timeoutMs = 8000 } = {}) {++ const ac = new AbortController();++ const timer = setTimeout(() => ac.abort(), timeoutMs);++ try {++ const hasBody = body !== undefined && method !== 'GET' && method !== 'HEAD';++ const res = await fetch(new URL(p, baseUrl), {++ method,++ redirect: 'manual',++ signal: ac.signal,++ headers: { accept: 'application/json', ...(hasBody ? { 'content-type': 'application/json' } : {}), ...headers },++ body: hasBody ? JSON.stringify(body) : undefined,++ });++ const ct = res.headers.get('content-type');++ if (/event-stream/i.test(ct || '')) { ac.abort(); return { status: res.status, kind: 'sse', json: null }; }++ const text = await res.text();++ const kind = kindOf(ct, text);++ let json = null;++ if (kind === 'json') { try { json = JSON.parse(text); } catch { json = null; } }++ const html = kind === 'html' ? text.slice(0, 1 << 20) : null;++ const loc = res.headers.get('location');++ // Where a redirect goes, without its query (a sign-in `next` or `state` differs every run).++ return { status: res.status, kind, json, html, location: loc ? loc.replace(/^https?:\/\/[^/]+/, '').split('?')[0] : null };++ } catch (err) {++ return { status: 0, kind: 'error', json: null, error: err.name === 'AbortError' ? 'timeout' : err.message };++ } finally {++ clearTimeout(timer);++ }++}++++/**++ * The request list for a set of extracted calls: every call anonymously, GETs also signed in.++ * Reads first, then writes (with an empty JSON body), so a write cannot change what a read sees.++ */++function requestsFor(calls, samples, { signedIn = true } = {}) {++ const seen = new Set();++ const reqs = [];++ for (const c of calls) {++ const p = concretePath(c.parts, samples);++ for (const auth of c.method === 'GET' && signedIn ? ['anon', 'user'] : ['anon']) {++ const key = `${c.method} ${p} ${auth}`;++ if (seen.has(key)) continue;++ seen.add(key);++ reqs.push({ method: c.method, path: p, auth, from: c.from });++ }++ }++ const rank = (r) => (r.method === 'GET' ? 0 : 1);++ return reqs.sort((a, b) => rank(a) - rank(b));++}++++/**++ * What the server answers a path nobody routes (per method): an HTML 404 is always "no route"; a++ * JSON catch-all 404 (`{ error: 'Not found' }`) is recognised by its body. → [{ method, status, kind, json }]++ */++async function notFoundProbe(baseUrl, headers = {}) {++ const out = [];++ for (const method of ['GET', 'POST']) {++ for (const p of ['/api/n-1-no-such-route', '/n-1-no-such-page']) {++ const got = await send(baseUrl, { method, path: p, headers, body: method === 'GET' ? undefined : {} });++ if (got.status === 404) out.push({ method, status: got.status, kind: got.kind, json: got.json });++ }++ }++ return out;++}++++/** Whether an answer is the server's unknown-path 404 rather than a route's own. */++function isNoRoute(got, probe = []) {++ if (got.status !== 404) return false;++ if (got.kind !== 'json') return true;++ return probe.some((fp) => fp.kind === 'json' && JSON.stringify(fp.json) === JSON.stringify(got.json));++}++++/** The failures of one replayed call against its recorded outcome (probe: N's unknown-path answers). */++function callProblems(rec, got, probe = []) {++ const problems = [];++ if (got.status === 0) return [`no answer (${got.error})`];++ if (isNoRoute(got, probe)) return ['the route is gone (it answers as an unknown path)'];++ if (!statusCompatible(rec.status, got.status)) problems.push(`answered ${got.status}, N-1 answered ${rec.status}`);++ else if (rec.kind === 'json' && got.kind !== 'json' && rec.status < 500) problems.push(`answered ${got.kind}, N-1 answered JSON`);++ if (rec.location && got.location && rec.status >= 300 && rec.status < 400 && got.location.split('?')[0] !== rec.location.split('?')[0]) problems.push(`redirects to ${got.location}, N-1 to ${rec.location}`);++ if (rec.reads && got.json != null && got.status >= 200 && got.status < 300) problems.push(...readProblems(rec.reads, got.json));++ return problems;++}++++// ── WebSockets ───────────────────────────────────────────────++++/**++ * The messages a client sends over its sockets: `x.send(JSON.stringify({ type: 'join', … }))` and++ * `helper({ type: … })` for each helper name. → [{ type, keys: [...] }] (keys merged per type)++ */++function wsSends(files, helpers = []) {++ const byType = new Map();++ const add = (obj) => {++ const t = obj.trim();++ if (!t.startsWith('{') || !t.endsWith('}')) return;++ let type = null; const keys = [];++ for (const entry of splitTop(t.slice(1, -1), ',')) {++ if (!entry || entry.startsWith('...')) continue;++ const kv = /^(?:['"]?)([A-Za-z_$][\w$-]*)(?:['"]?)\s*(?::\s*([\s\S]*))?$/.exec(entry);++ if (!kv) continue;++ if (kv[1] === 'type') {++ const lit = kv[2] && /^['"`]/.test(kv[2].trim()) ? readLiteral(kv[2].trim(), 0) : null;++ if (lit && lit.parts.length === 1 && lit.parts[0].text) type = lit.parts[0].text;++ } else keys.push(kv[1]);++ }++ if (!type) return;++ if (!byType.has(type)) byType.set(type, new Set());++ for (const k of keys) byType.get(type).add(k);++ };++ for (const { text } of files) {++ for (const m of text.matchAll(/\.send\(\s*JSON\.stringify\(/g)) {++ const args = callArgs(text, m.index + m[0].length - 1);++ if (args && args[0]) add(args[0]);++ }++ for (const name of helpers) {++ for (const m of text.matchAll(new RegExp(`(?<![\\w$.])${esc(name)}\\s*\\(\\s*(?=\\{)`, 'g'))) {++ const args = callArgs(text, m.index + m[0].indexOf('('));++ if (args && args[0]) add(args[0]);++ }++ }++ }++ return [...byType].map(([type, keys]) => ({ type, keys: [...keys].sort() })).sort((a, b) => (a.type < b.type ? -1 : 1));++}++++/** The message types a client acts on: `case 'x':` and `.type === 'x'`. */++function wsHandled(files) {++ const types = new Set();++ for (const { text } of files) {++ for (const m of text.matchAll(/\bcase\s+['"]([\w:.-]+)['"]\s*:/g)) types.add(m[1]);++ for (const m of text.matchAll(/\.type\s*[!=]==?\s*['"]([\w:.-]+)['"]/g)) types.add(m[1]);++ }++ return types;++}++++/**++ * One scripted socket session: connect to `url`, send each message in turn and wait until the server++ * has been quiet for `quietMs`. → every message received (parsed JSON objects)++ */++async function wsSession({ url, headers = {}, messages, quietMs = 250, maxMs = 3000 }) {++ const WebSocket = require('ws');++ const ws = new WebSocket(url, { headers });++ const got = [];++ let last = Date.now();++ ws.on('message', (d) => { try { const m = JSON.parse(String(d)); if (m && typeof m === 'object') got.push(m); } catch { /* not JSON */ } last = Date.now(); });++ await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); ws.once('unexpected-response', (req, res) => reject(new Error(`upgrade refused: ${res.statusCode}`))); });++ const settle = async () => {++ const start = Date.now();++ last = Date.now();++ while (Date.now() - last < quietMs && Date.now() - start < maxMs) await new Promise((r) => setTimeout(r, 25));++ };++ await settle();++ for (const m of messages) {++ if (ws.readyState !== 1) break;++ ws.send(JSON.stringify(m));++ await settle();++ }++ await new Promise((resolve) => { ws.once('close', resolve); ws.close(); setTimeout(resolve, 500); });++ return got;++}++++/** The messages to send for a client's sends, in `order` (then the rest), with keys filled from `values`. */++function wsMessages(sends, { order = [], values = {} } = {}) {++ const rank = (t) => { const i = order.indexOf(t); return i < 0 ? order.length : i; };++ return sends.slice().sort((a, b) => rank(a.type) - rank(b.type) || (a.type < b.type ? -1 : 1))++ .map((s) => {++ const m = { type: s.type };++ for (const k of s.keys) if (values[k] !== undefined) m[k] = values[k];++ return m;++ });++}++++/** Received messages grouped by type, for the types the client handles → { type: reads } */++function wsReads(received, handled, ids) {++ const byType = new Map();++ for (const m of received) {++ if (typeof m.type !== 'string' || !handled.has(m.type)) continue;++ if (!byType.has(m.type)) byType.set(m.type, []);++ byType.get(m.type).push(m);++ }++ const out = {};++ for (const [t, list] of [...byType].sort((a, b) => (a[0] < b[0] ? -1 : 1))) out[t] = readsOf(list, ids);++ return out;++}++++/** What `recorded` ({ type: reads }) finds missing in the messages N sent. */++function wsProblems(recorded, received) {++ const problems = [];++ for (const [type, reads] of Object.entries(recorded)) {++ const list = received.filter((m) => m.type === type);++ if (!list.length) { problems.push(`no '${type}' message any more`); continue; }++ problems.push(...readProblems(reads, list).map((p) => `'${type}': ${p.replace(/^\[\]\.?/, '')}`));++ }++ return problems;++}++++// ── SQL ──────────────────────────────────────────────────────++++const SQL_START = /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i;++++/** Every plain JS string literal in the files (a template only without ${}), comments skipped. */++function stringLiterals(files) {++ const out = [];++ for (const { text } of files) {++ for (let i = 0; i < text.length; i++) {++ const c = text[i];++ if (c === '/' && text[i + 1] === '/') { const nl = text.indexOf('\n', i); i = nl < 0 ? text.length : nl; continue; }++ if (c === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; continue; }++ if (c !== '\'' && c !== '"' && c !== '`') continue;++ const lit = readLiteral(text, i);++ if (!lit) continue;++ i = lit.end - 1;++ if (lit.parts.length === 1 && lit.parts[0].text != null) out.push(lit.parts[0].text);++ }++ }++ return out;++}++++/** The literals that read like one SQL statement (DML). */++function sqlLiterals(files) {++ const out = new Set();++ for (const t of stringLiterals(files)) {++ const sql = normalizeSql(t);++ if (SQL_START.test(sql) && /\b(FROM|INTO|SET|VALUES)\b/i.test(sql)) out.add(sql);++ }++ return [...out];++}++++/** SQL without its comments (-- to the end of a line, and block comments), outside quoted text. */++function stripSqlComments(sql) {++ let out = '';++ const s = String(sql);++ for (let i = 0; i < s.length; i++) {++ const c = s[i];++ if (c === '\'' || c === '"') {++ const j = s.indexOf(c, i + 1);++ const end = j < 0 ? s.length : j + 1;++ out += s.slice(i, end); i = end - 1; continue;++ }++ if (c === '-' && s[i + 1] === '-') { const nl = s.indexOf('\n', i); i = nl < 0 ? s.length : nl - 1; continue; }++ if (c === '/' && s[i + 1] === '*') { const e = s.indexOf('*/', i + 2); i = e < 0 ? s.length : e + 1; out += ' '; continue; }++ out += c;++ }++ return out;++}++++function normalizeSql(sql) {++ return stripSqlComments(sql).replace(/\s+/g, ' ').trim();++}++++/**++ * The DDL N-1 runs itself when it first needs a table or column (a module's CREATE TABLE IF NOT EXISTS,++ * a boot-time ALTER TABLE … ADD COLUMN) that `db` does not have yet. Each statement is applied to `db`++ * as it is found to apply; → the applied statements, tables first, in order.++ */++function lazyDDL(files, db) {++ const tables = () => new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));++ const columns = (t) => { try { return new Set(db.prepare(`PRAGMA table_xinfo("${t}")`).all().map((c) => c.name.toLowerCase())); } catch { return new Set(); } };++ const lits = stringLiterals(files).map(stripSqlComments);++ const applied = [];++ const apply = (ddl) => { try { db.exec(ddl); applied.push(ddl); return true; } catch { return false; } };++ for (const text of lits) {++ for (const m of text.matchAll(/CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+["`]?(\w+)["`]?\s*\(/gi)) {++ // A rebuild's scratch table (x_new) is the migration's own business, not a table N-1 uses.++ if (tables().has(m[1]) || /_(new|old|tmp|backup)$/i.test(m[1])) continue;++ const end = skipBalanced(text, m.index + m[0].length - 1, '(', ')');++ if (end >= 0) apply(normalizeSql(text.slice(m.index, end + 1)));++ }++ }++ for (const text of lits) {++ const m = /^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+(?:COLUMN\s+)?["`]?(\w+)["`]?[^;]*$/i.exec(text.trim().replace(/;$/, ''));++ if (!m || !tables().has(m[1]) || columns(m[1]).has(m[2].toLowerCase())) continue;++ apply(normalizeSql(text).replace(/;$/, ''));++ }++ return applied;++}++++/** Every file under dir with one of the extensions, as { name (relative to root), text }. */++function readTree(root, dirs, exts = ['.js']) {++ const out = [];++ const walk = (d) => {++ let entries = [];++ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }++ for (const e of entries) {++ const p = path.join(d, e.name);++ if (e.isDirectory()) { if (e.name !== 'node_modules' && !e.name.startsWith('.')) walk(p); } else if (exts.some((x) => e.name.endsWith(x))) out.push({ name: path.relative(root, p), text: fs.readFileSync(p, 'utf8') });++ }++ };++ for (const d of dirs) {++ const abs = path.join(root, d);++ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push({ name: d, text: fs.readFileSync(abs, 'utf8') });++ else walk(abs);++ }++ return out.sort((a, b) => (a.name < b.name ? -1 : 1));++}++++/** The schema as DDL, tables first (no internal or FTS shadow tables), then indexes, triggers and views. */++function schemaDDL(db) {++ const shadow = new Set(db.prepare('PRAGMA table_list').all().filter((t) => t.type === 'shadow').map((t) => t.name));++ const rows = db.prepare("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'").all()++ .filter((r) => !shadow.has(r.name));++ const order = { table: 0, index: 1, view: 2, trigger: 3 };++ return rows.sort((a, b) => order[a.type] - order[b.type] || (a.name < b.name ? -1 : 1)).map((r) => r.sql);++}++++/** Prepares every statement on db (a missing table or column fails the prepare). → [{ sql, error }] */++function prepareProblems(db, statements) {++ const problems = [];++ for (const sql of statements) {++ try { db.prepare(sql); } catch (e) { problems.push({ sql, error: e.message }); }++ }++ return problems;++}++++/**++ * INSERTs of N-1 that N would refuse: a column N made NOT NULL without a default that the INSERT does++ * not name. → [{ sql, error }]++ */++function insertProblems(db, statements) {++ const problems = [];++ const cols = new Map();++ const required = (table) => {++ if (!cols.has(table)) {++ let info = [];++ try { info = db.prepare(`PRAGMA table_xinfo("${table.replace(/"/g, '""')}")`).all(); } catch { info = []; }++ const pkInt = info.filter((c) => c.pk).length === 1 && info.find((c) => c.pk && /^INTEGER$/i.test(c.type));++ cols.set(table, info.filter((c) => c.notnull && c.dflt_value == null && !c.hidden && !(pkInt && c.pk)).map((c) => c.name));++ }++ return cols.get(table);++ };++ for (const sql of statements) {++ const m = /^\s*(?:INSERT|REPLACE)(?:\s+OR\s+\w+)?\s+INTO\s+["`]?(\w+)["`]?\s*\(([^)]*)\)\s*(VALUES|SELECT)/i.exec(sql);++ if (!m) continue;++ const named = new Set(m[2].split(',').map((c) => c.trim().replace(/^["`]|["`]$/g, '').toLowerCase()));++ const missing = required(m[1]).filter((c) => !named.has(c.toLowerCase()));++ if (missing.length) problems.push({ sql, error: `${m[1]}.${missing.join(', ')} is NOT NULL without a default, and N-1 does not set it` });++ }++ return problems;++}++++// ── Git ──────────────────────────────────────────────────────++++/** A detached worktree of `ref` in a temp directory sharing root's node_modules → { dir, sha, remove() }. */++function worktree(root, ref) {++ const sha = execFileSync('git', ['-C', root, 'rev-parse', '--verify', `${ref}^{commit}`], { encoding: 'utf8' }).trim();++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-'));++ const dir = path.join(tmp, 'release');++ execFileSync('git', ['-C', root, 'worktree', 'add', '--detach', dir, sha], { stdio: 'ignore' });++ fs.symlinkSync(path.join(root, 'node_modules'), path.join(dir, 'node_modules'), 'dir');++ return {++ dir, sha,++ remove() {++ try { execFileSync('git', ['-C', root, 'worktree', 'remove', '--force', dir], { stdio: 'ignore' }); } catch { /* */ }++ fs.rmSync(tmp, { recursive: true, force: true });++ },++ };++}++++/** Files of another repository at a commit (git show), e.g. Live's chat widget for Chat. → [{ name, text }] */++function gitFiles(repo, ref, dirs, exts = ['.js']) {++ const names = execFileSync('git', ['-C', repo, 'ls-tree', '-r', '--name-only', ref, '--', ...dirs], { encoding: 'utf8', maxBuffer: 64 << 20 })++ .split('\n').filter((n) => n && exts.some((x) => n.endsWith(x)) && !n.includes('node_modules/'));++ return names.sort().map((n) => ({ name: `${path.basename(repo)}:${n}`, text: execFileSync('git', ['-C', repo, 'show', `${ref}:${n}`], { encoding: 'utf8', maxBuffer: 64 << 20 }) }));++}++++// ── Reporting ────────────────────────────────────────────────++++function summarize(problems, max = 40) {++ const lines = problems.slice(0, max);++ if (problems.length > max) lines.push(`… and ${problems.length - max} more`);++ return lines.join('\n');++}++++module.exports = {++ readLiteral, callArgs, splitTop, pathParts, extractCalls, concretePath, fillPath, unfillPath, pageLinks, clientIdentifiers,++ flatten, readsOf, readProblems, statusCompatible,++ send, requestsFor, notFoundProbe, isNoRoute, callProblems,++ wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,++ stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,++ worktree, gitFiles, summarize,++};nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++/**++ * 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.++ */++const fs = require('fs');++const path = require('path');++++const out = process.env.N1_SQL_OUT;++if (out) {++ const Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3'));++ const seen = new Set();++ const note = (db, sql) => {++ try { if (process.env.CHAT_DB_PATH && path.resolve(db.name) === path.resolve(process.env.CHAT_DB_PATH)) seen.add(String(sql)); } catch { /* */ }++ };++ const prepare = Database.prototype.prepare;++ Database.prototype.prepare = function (sql, ...rest) { note(this, sql); return prepare.call(this, sql, ...rest); };++ const exec = Database.prototype.exec;++ Database.prototype.exec = function (sql, ...rest) { note(this, sql); return exec.call(this, sql, ...rest); };++ process.on('exit', () => { try { fs.writeFileSync(out, JSON.stringify([...seen])); } catch { /* */ } });++}nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * Chat's side of the N-1 harness (test/n-1/harness.js): its clients, how a release boots and is++ * seeded, where its SQL lives. Used by scripts/n-1-record.js (on N-1, in a temporary worktree) and by++ * test/n-1.test.js (on this checkout), so both boot and seed the same way.++ *++ * Chat's clients are its own pages' scripts (public/web, server/web), the openvibe-sdk chat client as++ * that release installed it, the links, scripts and forms of the openvibe.chat pages it served, and++ * OpenVibe.Live's chat widget, messenger, emote and sound pickers: every Live page call that++ * openvibe.live's nginx sends to Chat (/api/chat, /api/dm, /api/tts, /api/sounds) and the /ws/chat++ * messages Live's chat sends. At record++ * time Live's files come from the Live release in production: N1_LIVE_REPO (default ../OpenVibe.Live)++ * at N1_LIVE_REF (default its HEAD); the fixture names that commit.++ *++ * A release boots with its own test/helpers.js (stub Network and Live, Chat in-process) in a child++ * process (test/n-1/boot-child.js), which also seeds it.++ */++const fs = require('fs');++const path = require('path');++const { spawn, execFileSync } = require('child_process');++const { readTree, gitFiles } = require('./harness');++++const ROOT = path.join(__dirname, '..', '..');++const PRELOAD = path.join(__dirname, 'preload.js');++const CHILD = path.join(__dirname, 'boot-child.js');++const CHAT_PATHS = /^\/(api\/(chat|dm|tts)\/|api\/sounds(\/|$))/;++const LIVE_REPO = path.resolve(ROOT, process.env.N1_LIVE_REPO || '../OpenVibe.Live');++let liveRef = null;++++function baseEnv(extra) {++ return { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR || '/tmp', NODE_ENV: 'test', ...extra };++}++++module.exports = {++ service: 'chat',++++ /** Chat's pages and Live's chat surfaces (record time only: Live comes from its repository). */++ clientFiles(dir) {++ liveRef = execFileSync('git', ['-C', LIVE_REPO, 'rev-parse', '--verify', `${process.env.N1_LIVE_REF || 'HEAD'}^{commit}`], { encoding: 'utf8' }).trim();++ return [++ ...readTree(dir, ['public/web', 'server/web', 'node_modules/openvibe-sdk/src/chat.js'], ['.js']),++ ...gitFiles(LIVE_REPO, liveRef, ['public/js', 'public/fragments', 'public/obs', 'public/index.html', 'public/popout-chat.html', 'public/kiosk.html'], ['.js', '.html']),++ ];++ },++ clientReleases: () => ({ live: liveRef }),++ callers: [++ { name: 'api', prefix: '/api' },++ { name: 'apiSWR', prefix: '/api' },++ { name: 'dmApi', prefix: '/api/dm' },++ { name: 'fetch' },++ { name: 'navigator.sendBeacon', method: 'POST' },++ { name: 'call', object: true },++ ],++ strip: ['API', 'location.origin', 'window.location.origin'],++ /** Live's calls to the paths nginx sends to Chat; everything Chat's own pages call or link to. */++ keep: (pathname, method, file) => !String(file || '').startsWith('OpenVibe.Live:') || CHAT_PATHS.test(pathname),++ /** openvibe.chat's pages an open tab may hold (anonymous): their links, scripts and forms are replayed too. */++ crawl: ['/', '/rooms', '/r/n1-room', '/messages', '/settings', '/updates'],++ origins: ['https://openvibe.chat'],++ /** Values for template expressions, first match wins (the ids boot-child.js seeds). */++ samples: [++ [/stream/i, '500'],++ [/room/i, 'n1-room'],++ [/conv/i, '1'],++ [/message_?id|dataset\.id/i, '1'],++ [/channel_?user_?id|chatChannelUserId/i, '1001'],++ [/\.id\)*$|uid\)*$|user_?id\)*$/i, '1002'],++ [/after|before|latest|newest|oldest|cursor/i, '0'],++ [/user_?name/i, 'n1fan'],++ [/provider/i, 'tenor'],++ [/kind/i, 'follow'],++ [/^q$|query/i, 'n1'],++ ],++ ws: {++ path: '/ws/chat?stream=500',++ // The chat sockets' clients (Live's other pages open /ws/broadcast, /ws/control and /ws/call).++ files: (name) => /(public\/js\/chat\.js|popout-chat\.html|obs\/chat\.html|public\/web\/chat\.js)$/.test(name),++ headers: { origin: 'https://openvibe.live', 'cf-connecting-ip': '203.0.113.10' },++ order: ['join', 'join_stream', 'get-users', 'chat', 'join_room', 'room_message', 'leave_room', 'leave_stream', 'self-delete-history'],++ // Messages that tick on a clock rather than answer the client.++ ignore: [],++ },++++ sqlDirs: ['server'],++ ledgerTables: ['chat_meta'],++++ /** Chat migrates when it boots (server/index.js start → initDb); nothing to do first. */++ seed() {},++++ /** Boots the release in `dir` on dbPath → { url, headers(auth), wsValues(auth), close() }. */++ async boot({ dir, dbPath, sqlOut = '' }) {++ const child = spawn(process.execPath, ['-r', PRELOAD, CHILD], {++ cwd: dir,++ env: baseEnv({ N1_DB: dbPath, N1_SQL_OUT: sqlOut }),++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let log = '';++ let info = null;++ const ready = new Promise((resolve, reject) => {++ child.stdout.on('data', (c) => {++ log = (log + c).slice(-20000);++ const line = String(c).split('\n').find((l) => l.startsWith('{"n1":'));++ if (line && !info) { info = JSON.parse(line).n1; resolve(); }++ });++ child.on('exit', () => reject(new Error(`the release in ${dir} exited while booting:\n${log.slice(-3000)}`)));++ });++ child.stderr.on('data', (c) => { log = (log + c).slice(-20000); });++ const exited = new Promise((resolve) => child.on('exit', resolve));++ const timer = setTimeout(() => child.kill('SIGKILL'), 60000);++ await ready;++ clearTimeout(timer);++ fs.mkdirSync(path.dirname(dbPath), { recursive: true });++ return {++ url: info.url,++ ids: info.ids,++ log: () => log,++ headers: (auth) => (auth === 'user' ? { authorization: `Bearer ${info.token}` } : {}),++ wsValues: (auth) => ({++ streamId: info.ids.stream, channelUserId: info.ids.star, room: info.ids.room || 'n1-room',++ message: 'hello from the N-1 client', token: auth === 'user' ? info.token : undefined,++ }),++ async close() {++ if (child.exitCode == null) child.kill('SIGTERM');++ const t = setTimeout(() => { if (child.exitCode == null) child.kill('SIGKILL'); }, 8000);++ await exited;++ clearTimeout(t);++ },++ };++ },++};
@@ -4,7 +4,9 @@ on:
@@ -4,7 +4,9 @@ on:
branches: [main]
branches: [main]
pull_request:
pull_request:
jobs:
jobs:
- # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
+ # install, node --check, npm test (with test/n-1.test.js: the previous release's clients, Live's chat
+ # widget among them, and its SQL against this one, from test/fixtures/n-1) and openvibe-contracts-check:
+ # the shared test job (Track Q).
test:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
@@ -325,8 +325,19 @@ node scripts/table-authority.js # who writes each staged table (the ha
@@ -325,8 +325,19 @@ node scripts/table-authority.js # who writes each staged table (the ha
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
node scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones)
node scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones)
node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md)
node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md)
+N1_LIVE_REF=<live sha> npm run n-1:record # after a deploy: the N-1 fixtures from the deployed commit
```
```
+`test/n-1.test.js` (roadmap WS-P task 11, in `npm test`) runs the previous release's clients against
+this one and its SQL against this schema, from `test/fixtures/n-1/`: every call Chat's pages, the
+openvibe.chat pages' links, scripts and forms, and Live's chat widget, messenger and pickers make
+(status, JSON, the fields they read), the `/ws/chat` messages Live's chat sends with the replies it
+reads, and every statement the previous release runs, which must still prepare after this release
+migrated a database the previous one created. After each deploy,
+record the release now in production as the next N-1 (`npm run n-1:record [ref]`, with `N1_LIVE_REF`
+the Live release in production, from a Live checkout at `N1_LIVE_REPO`, default `../OpenVibe.Live`)
+and commit the fixtures.
+
`/ready` (openvibe-shared/ready shape: `status` ready/degraded/not_ready and `checks`) is 503 only
`/ready` (openvibe-shared/ready shape: `status` ready/degraded/not_ready and `checks`) is 503 only
when the `db` check (a read of `chat_messages`) fails. `live_sync` is optional: it reads degraded when
when the `db` check (a read of `chat_messages`) fails. `live_sync` is optional: it reads degraded when
the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a sync step has missed
the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a sync step has missed
@@ -352,7 +363,7 @@ server/net/service-auth.js service tokens: client (Chat → others) and guard (o
@@ -352,7 +363,7 @@ server/net/service-auth.js service tokens: client (Chat → others) and guard (o
server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)
server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)
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/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
-scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events
+scripts/ import-from-live, parity-check, parity, mirror-flush, table-authority, migrate-chat-preferences, subscribe-events, n-1-record
docs/ cutover.md, calls-cutover.md, staged-tables-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
docs/ cutover.md, calls-cutover.md, staged-tables-cutover.md, parity.md, live-patch.diff, capabilities-proposal/
```
```
@@ -8,7 +8,8 @@
@@ -8,7 +8,8 @@
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
"test": "node test/run.js",
- "import-from-live": "node scripts/import-from-live.js"
+ "import-from-live": "node scripts/import-from-live.js",
+ "n-1:record": "node scripts/n-1-record.js"
},
},
"keywords": [
"keywords": [
"openvibe",
"openvibe",
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Records the N-1 fixtures (roadmap WS-P task 11) from a release: what its client calls and reads,
+ * and what SQL it runs on which schema. test/n-1.test.js replays them against this checkout.
+ *
+ * npm run n-1:record # from HEAD: run it right after a deploy, from the deployed commit,
+ * # so the fixture is N-1 for the next release
+ * npm run n-1:record -- <ref> # from another commit (e.g. the sha in production's /release.json)
+ *
+ * The release is checked out into a temporary git worktree (sharing node_modules), seeded and booted
+ * 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.
+ */
+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 ROOT = path.join(__dirname, '..');
+const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
+
+(async () => {
+ 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 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})`);
+
+ // 1. The client: its calls, and the names it reads.
+ const files = svc.clientFiles(wt.dir);
+ const { calls, dynamic } = h.extractCalls({ files, callers: svc.callers, strip: svc.strip, keep: svc.keep, forms: !!svc.forms });
+ const ids = h.clientIdentifiers(files.map((f) => f.text));
+ console.log(` client: ${files.length} files, ${calls.length} call sites with a path (${dynamic} with a computed one, not replayed)`);
+
+ // 2. The release, seeded and booted; every call replayed.
+ await svc.seed({ dir: wt.dir, dbPath, dataDir });
+ server = await svc.boot({ dir: wt.dir, dbPath, dataDir, sqlOut });
+ const manifest = await (await fetch(`${server.url}/release.json`)).json();
+ const probe = await h.notFoundProbe(server.url);
+ const requests = h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false });
+ // Server-rendered pages: an open tab holds the page N-1 served, and follows what it links to.
+ if (svc.crawl) {
+ const have = new Set(requests.map((r) => `${r.method} ${r.path} ${r.auth}`));
+ const push = (r) => { const k = `${r.method} ${r.path} ${r.auth}`; if (!have.has(k)) { have.add(k); requests.push(r); } };
+ for (const page of svc.crawl) {
+ push({ method: 'GET', path: page, auth: 'anon', accept: 'html', from: ['page'] });
+ const got = await h.send(server.url, { method: 'GET', path: h.fillPath(page, server.ids), headers: { accept: 'text/html' } });
+ for (const l of got.html ? h.pageLinks(got.html, svc.origins) : []) {
+ const p = h.unfillPath(l.path, server.ids);
+ if (!svc.keep || svc.keep(p.split('?')[0], l.method, `page ${page}`)) push({ method: l.method, path: p, auth: 'anon', accept: 'html', from: [`page ${page}`] });
+ }
+ }
+ requests.sort((a, b) => (a.method === 'GET' ? 0 : 1) - (b.method === 'GET' ? 0 : 1));
+ }
+ const recorded = [];
+ let unanswered = 0; let noRoute = 0;
+ for (const req of requests) {
+ const got = await h.send(server.url, { method: req.method, path: h.fillPath(req.path, server.ids), headers: { ...(req.accept ? { accept: 'text/html' } : {}), ...server.headers(req.auth) }, body: req.method === 'GET' ? undefined : {} });
+ if (got.status === 0) { unanswered++; continue; }
+ // No route for this path on N-1 itself (a sample value its route does not take, or a dead call):
+ // nothing N has to keep.
+ if (h.isNoRoute(got, probe)) { noRoute++; continue; }
+ const rec = { method: req.method, path: req.path, auth: req.auth, ...(req.accept ? { accept: req.accept } : {}), status: got.status, kind: got.kind };
+ if (got.location) rec.location = got.location;
+ if (got.json != null && got.status >= 200 && got.status < 300) {
+ const reads = h.readsOf(got.json, ids);
+ if (Object.keys(reads).length) rec.reads = reads;
+ }
+ rec.from = req.from;
+ recorded.push(rec);
+ }
+ console.log(` replayed: ${recorded.length} requests (left out: ${unanswered} unanswered, ${noRoute} with no route on N-1)`);
+
+ // Sockets: the messages the client sends, in one session per sign-in state; what comes back.
+ let ws = null;
+ if (svc.ws) {
+ const wsFiles = svc.ws.files ? files.filter((f) => svc.ws.files(f.name)) : files;
+ const sends = h.wsSends(wsFiles, svc.ws.helpers);
+ const handled = h.wsHandled(wsFiles);
+ ws = { path: svc.ws.path, sends, sessions: [] };
+ for (const auth of ['anon', 'user']) {
+ const messages = h.wsMessages(sends, { order: svc.ws.order, values: server.wsValues(auth) });
+ const received = await h.wsSession({ url: server.url.replace(/^http/, 'ws') + svc.ws.path, headers: svc.ws.headers, messages });
+ ws.sessions.push({ auth, received: h.wsReads(received.filter((m) => !(svc.ws.ignore || []).includes(m.type)), handled, ids) });
+ }
+ console.log(` sockets: ${sends.length} message types sent; received, of the types the client handles: ${ws.sessions.map((x) => `${x.auth} ${Object.keys(x.received).length}`).join(', ')}`);
+ }
+ await server.close();
+ server = null;
+
+ // 3. The schema N-1 left, and the SQL it runs on it.
+ const db = new Database(dbPath);
+ const schema = h.schemaDDL(db);
+ const ledger = {};
+ for (const t of svc.ledgerTables || []) {
+ try { ledger[t] = db.prepare(`SELECT * FROM "${t}"`).all(); } catch { /* not there */ }
+ }
+ const userVersion = db.pragma('user_version', { simple: true });
+ const serverFiles = h.readTree(wt.dir, svc.sqlDirs);
+ // What N-1 creates itself on first use or at a full boot: in production it exists before N deploys.
+ const lazy = h.lazyDDL(serverFiles, db);
+ const ran = fs.existsSync(sqlOut) ? JSON.parse(fs.readFileSync(sqlOut, 'utf8')) : [];
+ const candidates = [...new Set([...ran.map(h.normalizeSql).filter((s) => /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i.test(s)), ...h.sqlLiterals(serverFiles)])].sort();
+ const failing = new Set(h.prepareProblems(db, candidates).map((p) => p.sql));
+ const statements = candidates.filter((s) => !failing.has(s));
+ db.close();
+ console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${schema.length} schema objects, ${lazy.length} created on first use`);
+
+ fs.mkdirSync(OUT, { recursive: true });
+ 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`);
+ fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);
+ console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
+ } finally {
+ if (server) await server.close();
+ wt.remove();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,2012 @@
@@ -0,0 +1,2012 @@
+{
+ "service": "chat",
+ "release": "739d3a1cdc22fbb0717e4df4abd6b7e4ad31a8f1",
+ "recorded_at": "2026-09-26T14:07:10.965Z",
+ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
+ "clients": {
+ "live": "8caa540466f9af86247febdbba1339b68919a42f"
+ },
+ "manifest": {
+ "service": "chat",
+ "release": "739d3a1cdc22",
+ "released_at": "2026-09-26T13:12:38.000Z",
+ "booted_at": "2026-09-26T14:07:06.104Z",
+ "contracts_version": "0.57.0",
+ "packages": {
+ "openvibe-shared": "1.22.0",
+ "openvibe-sdk": "0.11.0",
+ "openvibe-contracts": "0.57.0"
+ },
+ "min_client_release": null,
+ "mixed_version_window_hours": 24,
+ "components": {
+ "shell": {
+ "kind": "script",
+ "version": "739d3a1cdc22"
+ },
+ "server": {
+ "kind": "server",
+ "version": "739d3a1cdc22"
+ }
+ },
+ "assets": {},
+ "schema_generation": null,
+ "schema_compatible_from": null,
+ "contract_ranges": {},
+ "metrics_url": "/release-metrics"
+ },
+ "dynamic_calls": 53,
+ "calls": [
+ {
+ "method": "GET",
+ "path": "/api/chat/admin/logs",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/dashboard.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/admin/logs",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "rows": "array",
+ "total": "number",
+ "page": "number",
+ "limit": "number",
+ "totalPages": "number"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/dashboard.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/anon/n1fan",
+ "auth": "anon",
+ "status": 400,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/anon/n1fan",
+ "auth": "user",
+ "status": 400,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/channel/1001/history?limit=500",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_is_live": "null",
+ "messages[].source_slug": "null",
+ "messages[].source_channel": "string",
+ "latest_id": "number",
+ "liveSlots": "array",
+ "channel": "string",
+ "activeStreamId": "null",
+ "activeManagedId": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/channel/1001/history?limit=500",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_is_live": "null",
+ "messages[].source_slug": "null",
+ "messages[].source_channel": "string",
+ "latest_id": "number",
+ "liveSlots": "array",
+ "channel": "string",
+ "activeStreamId": "null",
+ "activeManagedId": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/filters/friendly",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "categories": "array",
+ "categories[]": "object",
+ "categories[].key": "string",
+ "categories[].label": "string",
+ "categories[].patterns": "array",
+ "categories[].patterns[]": "string"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/filters/friendly",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "categories": "array",
+ "categories[]": "object",
+ "categories[].key": "string",
+ "categories[].label": "string",
+ "categories[].patterns": "array",
+ "categories[].patterns[]": "string"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/gif/providers",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "providers": "object",
+ "providers.tenor": "boolean",
+ "providers.giphy": "boolean",
+ "defaultProvider": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/emotes.js",
+ "OpenVibe.Live:public/js/gif-picker.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/gif/providers",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "providers": "object",
+ "providers.tenor": "boolean",
+ "providers.giphy": "boolean",
+ "defaultProvider": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/emotes.js",
+ "OpenVibe.Live:public/js/gif-picker.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/gif/search?provider=tenor&q=n1",
+ "auth": "anon",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/gif-picker.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/gif/search?provider=tenor&q=n1",
+ "auth": "user",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/gif-picker.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/gif/trending?provider=tenor",
+ "auth": "anon",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/gif-picker.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/gif/trending?provider=tenor",
+ "auth": "user",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/gif-picker.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/global/history",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].stream_channel": "string",
+ "messages[].source_is_live": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_slug": "null",
+ "latest_id": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/global/history",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].stream_channel": "string",
+ "messages[].source_is_live": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_slug": "null",
+ "latest_id": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/global/history?after_id=&limit=200",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].stream_channel": "string",
+ "messages[].source_is_live": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_slug": "null",
+ "latest_id": "number",
+ "complete": "boolean",
+ "deleted_ids": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/global/history?after_id=&limit=200",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].stream_channel": "string",
+ "messages[].source_is_live": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_slug": "null",
+ "latest_id": "number",
+ "complete": "boolean",
+ "deleted_ids": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/global/history?after_id=0&limit=200",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].stream_channel": "string",
+ "messages[].source_is_live": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_slug": "null",
+ "latest_id": "number",
+ "complete": "boolean",
+ "deleted_ids": "array"
+ },
+ "from": [
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/global/history?after_id=0&limit=200",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].stream_channel": "string",
+ "messages[].source_is_live": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_slug": "null",
+ "latest_id": "number",
+ "complete": "boolean",
+ "deleted_ids": "array"
+ },
+ "from": [
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/global/history?limit=500",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].stream_channel": "string",
+ "messages[].source_is_live": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_slug": "null",
+ "latest_id": "number"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/global/history?limit=500",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].stream_channel": "string",
+ "messages[].source_is_live": "null",
+ "messages[].source_managed_id": "null",
+ "messages[].source_stream_title": "null",
+ "messages[].source_slug": "null",
+ "latest_id": "number"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/ice-servers",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "iceServers": "array",
+ "iceServers[]": "object",
+ "iceServers[].urls": "string"
+ },
+ "from": [
+ "public/web/call.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/ice-servers",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "iceServers": "array",
+ "iceServers[]": "object",
+ "iceServers[].urls": "string"
+ },
+ "from": [
+ "public/web/call.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/me/export",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/me/export",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "username": "string",
+ "total": "number",
+ "messages": "array"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/relay-user/1/n1fan",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "relayUser": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/relay-user/1/n1fan",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "relayUser": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/rooms",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "public": "array",
+ "public[]": "object",
+ "public[].id": "number",
+ "public[].slug": "string",
+ "public[].name": "string",
+ "public[].topic": "null",
+ "public[].kind": "string",
+ "public[].visibility": "string",
+ "public[].slow_seconds": "number",
+ "public[].message_count": "number",
+ "public[].last_message_at": "string",
+ "public[].created_at": "string",
+ "public[].members": "number",
+ "mine": "array"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/rooms",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "public": "array",
+ "public[]": "object",
+ "public[].id": "number",
+ "public[].slug": "string",
+ "public[].name": "string",
+ "public[].topic": "null",
+ "public[].kind": "string",
+ "public[].visibility": "string",
+ "public[].slow_seconds": "number",
+ "public[].message_count": "number",
+ "public[].last_message_at": "string",
+ "public[].created_at": "string",
+ "public[].members": "number",
+ "mine": "array",
+ "mine[]": "object",
+ "mine[].id": "number",
+ "mine[].slug": "string",
+ "mine[].name": "string",
+ "mine[].topic": "null",
+ "mine[].kind": "string",
+ "mine[].visibility": "string",
+ "mine[].slow_seconds": "number",
+ "mine[].message_count": "number",
+ "mine[].last_message_at": "string",
+ "mine[].created_at": "string",
+ "mine[].members": "number",
+ "mine[].role": "string",
+ "mine[].unread": "number",
+ "mine[].last_seen_at": "string"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/rooms/n1-room/messages?after=0&limit=200",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].user_id": "number",
+ "messages[].message": "string",
+ "messages[].created_at": "string",
+ "messages[].is_deleted": "number",
+ "messages[].username": "string",
+ "messages[].display_name": "string",
+ "messages[].profile_color": "string",
+ "messages[].user_role": "string",
+ "latest_id": "number"
+ },
+ "from": [
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/rooms/n1-room/messages?after=0&limit=200",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].user_id": "number",
+ "messages[].message": "string",
+ "messages[].created_at": "string",
+ "messages[].is_deleted": "number",
+ "messages[].username": "string",
+ "messages[].display_name": "string",
+ "messages[].profile_color": "string",
+ "messages[].user_role": "string",
+ "latest_id": "number"
+ },
+ "from": [
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/search",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/search",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "total": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/user/n1fan/profile",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "id": "number",
+ "username": "string",
+ "display_name": "string",
+ "messageCount": "number",
+ "followerCount": "number"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/user/n1fan/profile",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "id": "number",
+ "username": "string",
+ "display_name": "string",
+ "messageCount": "number",
+ "followerCount": "number"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/500/history?limit=500",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].source_stream_title": "string",
+ "messages[].source_managed_id": "null",
+ "messages[].source_is_live": "number",
+ "messages[].source_slug": "null",
+ "messages[].source_channel": "string",
+ "latest_id": "number",
+ "complete": "boolean",
+ "liveSlots": "array",
+ "channel": "string",
+ "activeStreamId": "number",
+ "activeManagedId": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/500/history?limit=500",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null",
+ "messages[].core_username": "null",
+ "messages[].source_stream_title": "string",
+ "messages[].source_managed_id": "null",
+ "messages[].source_is_live": "number",
+ "messages[].source_slug": "null",
+ "messages[].source_channel": "string",
+ "latest_id": "number",
+ "complete": "boolean",
+ "liveSlots": "array",
+ "channel": "string",
+ "activeStreamId": "number",
+ "activeManagedId": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/500/replay",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/app-media.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat/500/replay",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array",
+ "messages[]": "object",
+ "messages[].id": "number",
+ "messages[].stream_id": "number",
+ "messages[].user_id": "number",
+ "messages[].anon_id": "null",
+ "messages[].username": "string",
+ "messages[].message": "string",
+ "messages[].message_type": "string",
+ "messages[].metadata": "null",
+ "messages[].is_global": "number",
+ "messages[].is_deleted": "number",
+ "messages[].reply_to_id": "null",
+ "messages[].source_platform": "null",
+ "messages[].timestamp": "string",
+ "messages[].channel_user_id": "number",
+ "messages[].avatar_url": "null",
+ "messages[].profile_color": "null",
+ "messages[].role": "null",
+ "messages[].display_name": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/app-media.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/blocks",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js",
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/blocks",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "blocked": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js",
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/blocks/check/1002",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/blocks/check/1002",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "blocked": "boolean"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js",
+ "node_modules/openvibe-sdk/src/chat.js",
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "conversations": "array",
+ "conversations[]": "object",
+ "conversations[].id": "number",
+ "conversations[].name": "null",
+ "conversations[].is_group": "number",
+ "conversations[].updated_at": "string",
+ "conversations[].last_message": "string",
+ "conversations[].last_message_at": "string",
+ "conversations[].unread_count": "number",
+ "conversations[].participants": "array",
+ "conversations[].participants[]": "object",
+ "conversations[].participants[].id": "number",
+ "conversations[].participants[].username": "string",
+ "conversations[].participants[].display_name": "string",
+ "conversations[].participants[].avatar_url": "null",
+ "conversations[].participants[].profile_color": "string"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js",
+ "node_modules/openvibe-sdk/src/chat.js",
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "conversation": "object",
+ "conversation.id": "number",
+ "conversation.name": "null",
+ "conversation.is_group": "number",
+ "conversation.created_by": "number",
+ "conversation.created_at": "string",
+ "conversation.updated_at": "string",
+ "conversation.participants": "array",
+ "conversation.participants[]": "object",
+ "conversation.participants[].id": "number",
+ "conversation.participants[].username": "string",
+ "conversation.participants[].display_name": "string",
+ "conversation.participants[].avatar_url": "null",
+ "conversation.participants[].profile_color": "string"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1/messages?after=0&limit=50",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1/messages?after=0&limit=50",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1/messages?limit=50&before=0",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1/messages?limit=50&before=0",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1/messages?limit=50",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1/messages?limit=50",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1/messages?after=0&limit=100",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/conversations/1/messages?after=0&limit=100",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "messages": "array"
+ },
+ "from": [
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/unread",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js",
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/unread",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "unread": "number"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js",
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/users/search?q=",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/users/search?q=",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "users": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/users/search?q=n1fan",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/dm/users/search?q=n1fan",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "users": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/sounds/alert/mine",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/channel-uploads.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/sounds/alert/mine",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "donation": "object",
+ "donation.set": "boolean",
+ "donation.url": "null",
+ "goal": "object",
+ "goal.set": "boolean",
+ "goal.url": "null"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/channel-uploads.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/sounds/all/1",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "sounds": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/emotes.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/sounds/all/1",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "sounds": "array"
+ },
+ "from": [
+ "OpenVibe.Live:public/js/emotes.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/web/chat.css?v=f8b0b316ea",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/theme-loader.js?v=d9b9af081efe",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/navbar.js?v=b50d9f47fc34",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/footer.js?v=b99e512398d5",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/web/chat.js?v=aedc88ef89",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/rooms",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/messages",
+ "auth": "anon",
+ "accept": "html",
+ "status": 303,
+ "kind": "html",
+ "location": "/auth/login",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/settings",
+ "auth": "anon",
+ "accept": "html",
+ "status": 303,
+ "kind": "html",
+ "location": "/auth/login",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/auth/login?next=%2F",
+ "auth": "anon",
+ "accept": "html",
+ "status": 302,
+ "kind": "html",
+ "location": "/oauth/authorize",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/sitemap.xml",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/updates",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/auth/login?next=%2Frooms",
+ "auth": "anon",
+ "accept": "html",
+ "status": 302,
+ "kind": "html",
+ "location": "/oauth/authorize",
+ "from": [
+ "page /rooms"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/r/{@room}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /rooms"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/r/n1-room",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/auth/login?next=%2Fr%2F{@room}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 302,
+ "kind": "html",
+ "location": "/oauth/authorize",
+ "from": [
+ "page /r/n1-room"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/auth/login?next=%2Fupdates",
+ "auth": "anon",
+ "accept": "html",
+ "status": 302,
+ "kind": "html",
+ "location": "/oauth/authorize",
+ "from": [
+ "page /updates"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/shipped.js?v=18e4e7ae4ec3",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /updates"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/chat/admin/purge",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/dashboard.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/chat/admin/purge/preview",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/dashboard.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/chat/rooms",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/chat/rooms/n1-room/messages/1",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/chat/rooms/n1-room/read",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "public/web/chat.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/chat/send",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/dm/blocks/1002",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/dm/blocks/1002",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/dm/conversations",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js",
+ "node_modules/openvibe-sdk/src/chat.js"
+ ]
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/dm/conversations/1",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/dm/conversations/1/messages",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/dm/conversations/1/messages/1",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/dm/conversations/1/participants",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/dm/conversations/1/participants/1002",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/dm/conversations/1/read",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/messenger.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/sounds",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/channel-uploads.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/sounds/alert/follow",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/channel-uploads.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/sounds/alert/follow",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/channel-uploads.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/sounds/1",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/channel-uploads.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/tts/admin/test",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "OpenVibe.Live:public/js/broadcast.js"
+ ]
+ }
+ ],
+ "ws": {
+ "path": "/ws/chat?stream=500",
+ "sends": [
+ {
+ "type": "chat",
+ "keys": [
+ "message",
+ "streamId"
+ ]
+ },
+ {
+ "type": "get-users",
+ "keys": []
+ },
+ {
+ "type": "join",
+ "keys": [
+ "channelUserId",
+ "streamId",
+ "token"
+ ]
+ },
+ {
+ "type": "join_room",
+ "keys": [
+ "room"
+ ]
+ },
+ {
+ "type": "room_message",
+ "keys": [
+ "message"
+ ]
+ },
+ {
+ "type": "self-delete-history",
+ "keys": [
+ "streamId"
+ ]
+ }
+ ],
+ "sessions": [
+ {
+ "auth": "anon",
+ "received": {
+ "auth": {
+ "[].type": "string",
+ "[].authenticated": "boolean",
+ "[].newcomer": "boolean",
+ "[].username": "string",
+ "[].core_username": "null",
+ "[].role": "string",
+ "[].user_id": "null",
+ "[].slowmode_seconds": "number",
+ "[].sub_only": "boolean",
+ "[].allow_auto_delete": "boolean",
+ "[].allow_self_delete_all": "boolean",
+ "[].gifs_enabled": "boolean",
+ "[].custom_emotes_enabled": "boolean",
+ "[].custom_sounds_enabled": "boolean",
+ "[].uploads_mods_only": "boolean",
+ "[].max_sound_seconds": "number",
+ "[].emote_scale": "number",
+ "[].soundboard_enabled": "boolean",
+ "[].soundboard_allow_pitch": "boolean",
+ "[].soundboard_allow_speed": "boolean",
+ "[].slur_filter_enabled": "boolean",
+ "[].slur_filter_use_builtin": "boolean",
+ "[].slur_filter_disabled_categories": "array",
+ "[].slur_filter_terms": "array",
+ "[].slur_filter_regexes": "array",
+ "[].slur_filter_nudge_message": "string",
+ "[].min_auto_delete_minutes": "number",
+ "[].channel_language": "string"
+ },
+ "chat": {
+ "[].type": "string",
+ "[].username": "string",
+ "[].core_username": "null",
+ "[].user_id": "null",
+ "[].anon_id": "string",
+ "[].role": "string",
+ "[].message": "string",
+ "[].stream_id": "number",
+ "[].channel_user_id": "number",
+ "[].is_global": "boolean",
+ "[].avatar_url": "null",
+ "[].profile_color": "string",
+ "[].timestamp": "string",
+ "[].id": "number"
+ },
+ "delete-messages": {
+ "[].type": "string",
+ "[].ids": "array",
+ "[].ids[]": "number"
+ },
+ "room_error": {
+ "[].type": "string",
+ "[].room": "string",
+ "[].code": "string",
+ "[].message": "string"
+ },
+ "room_joined": {
+ "[].type": "string",
+ "[].room": "string",
+ "[].kind": "string",
+ "[].role": "null",
+ "[].can": "object",
+ "[].can.post": "boolean",
+ "[].can.moderate": "boolean",
+ "[].can.manage": "boolean",
+ "[].can.join": "boolean",
+ "[].can.talk": "boolean"
+ },
+ "self-delete-result": {
+ "[].type": "string",
+ "[].count": "number",
+ "[].scope": "string"
+ },
+ "system": {
+ "[].type": "string",
+ "[].message": "string",
+ "[].timestamp": "string"
+ },
+ "user-count": {
+ "[].type": "string",
+ "[].count": "number",
+ "[].stream_id": "number"
+ },
+ "users-list": {
+ "[].type": "string",
+ "[].users": "object",
+ "[].users.logged": "array",
+ "[].users.anonCount": "number"
+ }
+ }
+ },
+ {
+ "auth": "user",
+ "received": {
+ "auth": {
+ "[].type": "string",
+ "[].authenticated": "boolean",
+ "[].newcomer": "boolean",
+ "[].username": "string",
+ "[].core_username": "string",
+ "[].role": "string",
+ "[].user_id": "number",
+ "[].slowmode_seconds": "number",
+ "[].sub_only": "boolean",
+ "[].allow_auto_delete": "boolean",
+ "[].allow_self_delete_all": "boolean",
+ "[].gifs_enabled": "boolean",
+ "[].custom_emotes_enabled": "boolean",
+ "[].custom_sounds_enabled": "boolean",
+ "[].uploads_mods_only": "boolean",
+ "[].max_sound_seconds": "number",
+ "[].emote_scale": "number",
+ "[].soundboard_enabled": "boolean",
+ "[].soundboard_allow_pitch": "boolean",
+ "[].soundboard_allow_speed": "boolean",
+ "[].slur_filter_enabled": "boolean",
+ "[].slur_filter_use_builtin": "boolean",
+ "[].slur_filter_disabled_categories": "array",
+ "[].slur_filter_terms": "array",
+ "[].slur_filter_regexes": "array",
+ "[].slur_filter_nudge_message": "string",
+ "[].min_auto_delete_minutes": "number",
+ "[].channel_language": "string"
+ },
+ "chat": {
+ "[].type": "string",
+ "[].username": "string",
+ "[].core_username": "string",
+ "[].user_id": "number",
+ "[].anon_id": "null",
+ "[].role": "string",
+ "[].message": "string",
+ "[].stream_id": "number",
+ "[].channel_user_id": "number",
+ "[].is_global": "boolean",
+ "[].avatar_url": "null",
+ "[].profile_color": "string",
+ "[].timestamp": "string",
+ "[].id": "number"
+ },
+ "coin_earned": {
+ "[].type": "string",
+ "[].coins": "number",
+ "[].total": "number",
+ "[].streamerId": "number",
+ "[].reason": "string"
+ },
+ "delete-messages": {
+ "[].type": "string",
+ "[].ids": "array",
+ "[].ids[]": "number"
+ },
+ "room_joined": {
+ "[].type": "string",
+ "[].room": "string",
+ "[].kind": "string",
+ "[].role": "string",
+ "[].can": "object",
+ "[].can.post": "boolean",
+ "[].can.moderate": "boolean",
+ "[].can.manage": "boolean",
+ "[].can.join": "boolean",
+ "[].can.talk": "boolean"
+ },
+ "room_message": {
+ "[].type": "string",
+ "[].room": "string",
+ "[].message": "object",
+ "[].message.id": "number",
+ "[].message.user_id": "number",
+ "[].message.message": "string",
+ "[].message.created_at": "string",
+ "[].message.is_deleted": "number",
+ "[].message.username": "string",
+ "[].message.display_name": "string",
+ "[].message.profile_color": "string",
+ "[].message.user_role": "string"
+ },
+ "self-delete-result": {
+ "[].type": "string",
+ "[].count": "number",
+ "[].scope": "string"
+ },
+ "system": {
+ "[].type": "string",
+ "[].message": "string",
+ "[].timestamp": "string"
+ },
+ "user-count": {
+ "[].type": "string",
+ "[].count": "number",
+ "[].stream_id": "number"
+ },
+ "users-list": {
+ "[].type": "string",
+ "[].users": "object",
+ "[].users.logged": "array",
+ "[].users.logged[]": "object",
+ "[].users.logged[].username": "string",
+ "[].users.logged[].display_name": "string",
+ "[].users.logged[].avatar_url": "null",
+ "[].users.logged[].role": "string",
+ "[].users.anonCount": "number"
+ }
+ }
+ }
+ ]
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,322 @@
@@ -0,0 +1,322 @@
+{
+ "service": "chat",
+ "release": "739d3a1cdc22fbb0717e4df4abd6b7e4ad31a8f1",
+ "recorded_at": "2026-09-26T14:07:10.965Z",
+ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
+ "user_version": 0,
+ "schema": [
+ "CREATE TABLE audio_requests (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n room TEXT NOT NULL,\n stream_id INTEGER,\n channel_user_id INTEGER,\n kind TEXT NOT NULL CHECK(kind IN ('tts', 'channel-sound', 'soundboard')),\n state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'playing', 'played', 'skipped', 'failed')),\n requested_by TEXT,\n identity_key TEXT,\n label TEXT,\n payload TEXT NOT NULL DEFAULT '{}',\n dedupe_key TEXT,\n attempts INTEGER NOT NULL DEFAULT 0,\n duration_ms INTEGER,\n error TEXT,\n actor TEXT,\n created_at INTEGER NOT NULL,\n started_at INTEGER,\n finished_at INTEGER,\n UNIQUE(room, dedupe_key)\n)",
+ "CREATE TABLE bridge_applied (\n key TEXT PRIMARY KEY,\n result TEXT,\n applied_at INTEGER NOT NULL\n)",
+ "CREATE TABLE bridge_refs (\n boot TEXT NOT NULL,\n ref INTEGER NOT NULL,\n id INTEGER NOT NULL,\n at INTEGER NOT NULL,\n PRIMARY KEY (boot, ref)\n)",
+ "CREATE TABLE calls (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n kind TEXT NOT NULL CHECK(kind IN ('channel', 'direct', 'stream')),\n state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending', 'ringing', 'active', 'ended', 'missed', 'declined', 'failed')),\n channel_id TEXT NOT NULL,\n stream_id INTEGER,\n created_by INTEGER,\n created_by_subject TEXT,\n target_user_id INTEGER,\n target_subject TEXT,\n end_reason TEXT,\n created_at INTEGER NOT NULL,\n started_at INTEGER,\n answered_at INTEGER,\n ended_at INTEGER\n)",
+ "CREATE TABLE channel_moderation_settings (\n channel_id INTEGER PRIMARY KEY,\n slow_mode_seconds INTEGER DEFAULT 0,\n followers_only INTEGER DEFAULT 0,\n emote_only INTEGER DEFAULT 0,\n allow_anonymous INTEGER DEFAULT 1,\n links_allowed INTEGER DEFAULT 1,\n gifs_enabled INTEGER DEFAULT 1,\n account_age_gate_hours INTEGER DEFAULT 0,\n caps_percentage_limit INTEGER DEFAULT 0,\n aggressive_filter INTEGER DEFAULT 0,\n max_message_length INTEGER DEFAULT 500,\n slur_filter_enabled INTEGER DEFAULT 0,\n slur_filter_use_builtin INTEGER DEFAULT 1,\n slur_filter_terms TEXT DEFAULT '',\n slur_filter_regexes TEXT DEFAULT '',\n slur_filter_nudge_message TEXT DEFAULT '',\n slur_filter_disabled_categories TEXT DEFAULT '[]',\n ip_approval_mode INTEGER DEFAULT 0,\n soundboard_enabled INTEGER DEFAULT 1,\n soundboard_allow_pitch INTEGER DEFAULT 1,\n soundboard_allow_speed INTEGER DEFAULT 1,\n soundboard_banned_ids TEXT DEFAULT '',\n viewer_auto_delete_enabled INTEGER DEFAULT 1,\n viewer_delete_all_enabled INTEGER DEFAULT 1,\n custom_emotes_enabled INTEGER DEFAULT 1,\n custom_sounds_enabled INTEGER DEFAULT 1,\n max_sound_seconds INTEGER DEFAULT 10,\n uploads_mods_only INTEGER DEFAULT 0,\n mods_can_edit_about INTEGER DEFAULT 0,\n donation_sound_url TEXT,\n donation_sound_mime TEXT,\n goal_sound_url TEXT,\n goal_sound_mime TEXT,\n emote_scale INTEGER DEFAULT 100,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n tts_max_length INTEGER DEFAULT 200,\n sound_min_speed REAL DEFAULT 0.5,\n sound_max_speed REAL DEFAULT 3.0,\n sound_min_pitch_cents INTEGER DEFAULT -1200,\n sound_max_pitch_cents INTEGER DEFAULT 1200,\n emote_size_min INTEGER DEFAULT 50,\n emote_size_max INTEGER DEFAULT 200,\n sounds_mods_only INTEGER DEFAULT 0,\n sub_only INTEGER DEFAULT 0 -- sub-only chat: active subscribers and the room's moderators\n)",
+ "CREATE TABLE channel_moderators (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n added_by INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(channel_id, user_id)\n)",
+ "CREATE TABLE channel_sounds (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_owner_id INTEGER NOT NULL, -- streamer whose channel this sound belongs to\n command TEXT NOT NULL, -- trigger word (without leading '!'), lowercased\n url TEXT NOT NULL, -- on-disk path of the audio file\n mime TEXT DEFAULT 'audio/mpeg',\n duration_seconds REAL DEFAULT 0,\n created_by INTEGER, -- uploader user id (NULL if removed user)\n created_by_name TEXT DEFAULT '',\n is_approved INTEGER DEFAULT 1,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n emote_code TEXT DEFAULT '',\n created_by_subject_id TEXT\n -- Multiple sounds may share a command; playback picks one at random.\n -- media_url / media_asset_id stay on Live's copy (its asset-sync mirrors files to Media).\n)",
+ "CREATE TABLE chat_ai_summaries (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scope TEXT NOT NULL,\n subject_id INTEGER NOT NULL DEFAULT 0,\n window TEXT NOT NULL,\n overview TEXT DEFAULT '',\n memory_json TEXT DEFAULT '',\n timeline_json TEXT DEFAULT '[]',\n message_count INTEGER DEFAULT 0,\n window_message_count INTEGER DEFAULT 0,\n last_message_id INTEGER DEFAULT 0,\n window_label TEXT DEFAULT '',\n window_start DATETIME,\n window_end DATETIME,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(scope, subject_id, window)\n)",
+ "CREATE TABLE chat_event_inbox (\n consumer TEXT NOT NULL,\n event_id TEXT NOT NULL,\n processed_at INTEGER NOT NULL,\n PRIMARY KEY (consumer, event_id)\n)",
+ "CREATE TABLE chat_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER, -- Live stream (session) id\n user_id INTEGER, -- Live user id; NULL for anon\n anon_id TEXT, -- 'anon12345' format\n username TEXT,\n message TEXT NOT NULL,\n message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')),\n metadata TEXT, -- JSON sidecar for rich events (donation/goal-reached)\n is_global INTEGER DEFAULT 0,\n is_deleted INTEGER DEFAULT 0,\n is_filtered INTEGER DEFAULT 0,\n reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,\n source_platform TEXT,\n deleted_by INTEGER,\n deleted_at DATETIME,\n auto_delete_at DATETIME,\n timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,\n channel_user_id INTEGER, -- the broadcaster whose channel room this is\n subject_id TEXT -- Network subject of the author (usr_…), when known\n)",
+ "CREATE TABLE chat_meta (\n key TEXT PRIMARY KEY,\n value TEXT\n)",
+ "CREATE TABLE chat_timeline_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scope TEXT NOT NULL DEFAULT 'global',\n subject_id INTEGER NOT NULL DEFAULT 0,\n ts DATETIME NOT NULL,\n label TEXT NOT NULL,\n detail TEXT DEFAULT '',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE ctx_channels (\n id INTEGER PRIMARY KEY,\n user_id INTEGER UNIQUE,\n title TEXT,\n synced_at INTEGER\n)",
+ "CREATE TABLE ctx_managed_streams (\n id INTEGER PRIMARY KEY,\n user_id INTEGER,\n slug TEXT,\n title TEXT,\n sort_order INTEGER DEFAULT 0,\n created_at DATETIME,\n synced_at INTEGER\n)",
+ "CREATE TABLE ctx_streams (\n id INTEGER PRIMARY KEY,\n user_id INTEGER,\n channel_id INTEGER,\n managed_stream_id INTEGER,\n title TEXT,\n is_live INTEGER DEFAULT 0,\n started_at DATETIME,\n ended_at DATETIME,\n created_at DATETIME,\n synced_at INTEGER\n)",
+ "CREATE TABLE ctx_sync (\n key TEXT PRIMARY KEY,\n value TEXT,\n updated_at INTEGER\n)",
+ "CREATE TABLE ctx_users (\n id INTEGER PRIMARY KEY,\n username TEXT,\n display_name TEXT,\n avatar_url TEXT,\n profile_color TEXT,\n role TEXT DEFAULT 'user',\n is_banned INTEGER DEFAULT 0,\n ban_reason TEXT,\n is_owner INTEGER DEFAULT 0,\n created_at DATETIME,\n subject_id TEXT,\n synced_at INTEGER\n)",
+ "CREATE TABLE deploy_releases (\n head TEXT PRIMARY KEY,\n message_id INTEGER,\n first_via TEXT NOT NULL CHECK(first_via IN ('bridge', 'events')),\n bridge_at INTEGER,\n event_at INTEGER,\n event_id TEXT,\n commit_count INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL\n)",
+ "CREATE TABLE dm_blocks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n blocker_id INTEGER NOT NULL,\n blocked_id INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n blocker_subject_id TEXT,\n UNIQUE(blocker_id, blocked_id)\n)",
+ "CREATE TABLE dm_conversations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT,\n is_group INTEGER DEFAULT 0,\n created_by INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE dm_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n conversation_id INTEGER NOT NULL,\n sender_id INTEGER NOT NULL,\n message TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n sender_subject_id TEXT,\n FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE\n)",
+ "CREATE TABLE dm_participants (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n conversation_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n last_read_at DATETIME DEFAULT '1970-01-01 00:00:00',\n joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n subject_id TEXT,\n UNIQUE(conversation_id, user_id),\n FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE\n)",
+ "CREATE TABLE emotes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n url TEXT NOT NULL,\n animated INTEGER DEFAULT 0,\n width INTEGER DEFAULT 28,\n height INTEGER DEFAULT 28,\n is_global INTEGER DEFAULT 0,\n is_approved INTEGER DEFAULT 1,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n channel_owner_id INTEGER,\n size INTEGER DEFAULT 100,\n media_url TEXT,\n media_asset_id INTEGER\n)",
+ "CREATE TABLE events_outbox (\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n event_id TEXT NOT NULL UNIQUE,\n event_type TEXT NOT NULL,\n event TEXT NOT NULL,\n created_at TEXT NOT NULL,\n sent_at TEXT,\n attempts INTEGER DEFAULT 0,\n last_error TEXT\n)",
+ "CREATE TABLE hidden_relay_users (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id INTEGER,\n platform TEXT NOT NULL,\n external_username TEXT NOT NULL,\n action TEXT DEFAULT 'hide' CHECK(action IN ('hide','ban')),\n reason TEXT,\n created_by INTEGER,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE import_hold (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source_table TEXT NOT NULL,\n source_pk TEXT NOT NULL,\n reason TEXT NOT NULL,\n row_json TEXT NOT NULL,\n held_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(source_table, source_pk)\n)",
+ "CREATE TABLE import_runs (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n live_db TEXT,\n dry_run INTEGER DEFAULT 0,\n counts TEXT,\n started_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n finished_at DATETIME\n)",
+ "CREATE TABLE live_mirror_outbox (\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n tbl TEXT NOT NULL,\n op TEXT NOT NULL CHECK(op IN ('upsert', 'delete')),\n pk TEXT NOT NULL,\n created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))\n)",
+ "CREATE TABLE moderation_actions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scope_type TEXT NOT NULL DEFAULT 'site',\n scope_id INTEGER,\n actor_user_id INTEGER,\n target_user_id INTEGER,\n action_type TEXT NOT NULL,\n details TEXT DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n actor_subject_id TEXT\n)",
+ "CREATE TABLE network_blocks (\n blocker_subject TEXT NOT NULL,\n blocked_subject TEXT NOT NULL,\n active INTEGER NOT NULL,\n revision INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n PRIMARY KEY (blocker_subject, blocked_subject)\n )",
+ "CREATE TABLE pending_ip_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n channel_id INTEGER NOT NULL,\n stream_id INTEGER,\n ip_address TEXT NOT NULL,\n user_id INTEGER,\n anon_id TEXT,\n username TEXT,\n message TEXT NOT NULL,\n status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','denied')),\n reviewed_by INTEGER,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE relay_users (\n platform TEXT NOT NULL,\n username TEXT NOT NULL,\n display_name TEXT,\n first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,\n message_count INTEGER DEFAULT 0,\n PRIMARY KEY (platform, username)\n)",
+ "CREATE TABLE room_attachments (\n room_id INTEGER NOT NULL,\n service TEXT NOT NULL,\n resource TEXT NOT NULL,\n title TEXT,\n attached_by INTEGER NOT NULL,\n attached_by_subject TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (room_id, service, resource)\n )",
+ "CREATE TABLE room_members (\n room_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n role TEXT NOT NULL DEFAULT 'member',\n last_read_id INTEGER NOT NULL DEFAULT 0,\n joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen_at DATETIME,\n PRIMARY KEY (room_id, user_id)\n )",
+ "CREATE TABLE room_messages (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n room_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n subject_id TEXT,\n message TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n is_deleted INTEGER NOT NULL DEFAULT 0,\n deleted_by INTEGER\n )",
+ "CREATE TABLE rooms (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT UNIQUE NOT NULL,\n name TEXT NOT NULL,\n topic TEXT,\n kind TEXT NOT NULL DEFAULT 'community',\n visibility TEXT NOT NULL DEFAULT 'public',\n owner_id INTEGER NOT NULL,\n owner_subject TEXT,\n slow_seconds INTEGER NOT NULL DEFAULT 0,\n message_count INTEGER NOT NULL DEFAULT 0,\n last_message_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n archived_at DATETIME\n , join_role TEXT)",
+ "CREATE TABLE stream_first_chats (\n chatter_key TEXT NOT NULL,\n channel_user_id INTEGER NOT NULL,\n first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (chatter_key, channel_user_id)\n)",
+ "CREATE TABLE table_authority (\n table_name TEXT PRIMARY KEY,\n authority TEXT NOT NULL CHECK(authority IN ('chat', 'live')),\n note TEXT\n)",
+ "CREATE TABLE tts_voice_overrides (\n identity_key TEXT PRIMARY KEY,\n voice TEXT,\n pitch INTEGER,\n speed INTEGER,\n gap INTEGER DEFAULT 0,\n set_by INTEGER,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE user_tags (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n tag_id TEXT NOT NULL,\n source TEXT DEFAULT 'shop',\n granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(user_id, tag_id)\n)",
+ "CREATE INDEX idx_audio_requests_room_state ON audio_requests(room, state, id)",
+ "CREATE INDEX idx_calls_channel_state ON calls(channel_id, state)",
+ "CREATE INDEX idx_calls_open ON calls(state) WHERE state IN ('pending', 'ringing', 'active')",
+ "CREATE INDEX idx_channel_mods_channel ON channel_moderators(channel_id)",
+ "CREATE INDEX idx_channel_mods_user ON channel_moderators(user_id)",
+ "CREATE INDEX idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command)",
+ "CREATE INDEX idx_channel_sounds_owner ON channel_sounds(channel_owner_id)",
+ "CREATE INDEX idx_chat_anon ON chat_messages(anon_id)",
+ "CREATE INDEX idx_chat_autodelete ON chat_messages(auto_delete_at)",
+ "CREATE INDEX idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp)",
+ "CREATE INDEX idx_chat_stream_id ON chat_messages(stream_id)",
+ "CREATE INDEX idx_chat_stream_ts ON chat_messages(stream_id, timestamp)",
+ "CREATE INDEX idx_chat_timestamp ON chat_messages(timestamp)",
+ "CREATE UNIQUE INDEX idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label)",
+ "CREATE INDEX idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted)",
+ "CREATE INDEX idx_chat_user_id ON chat_messages(user_id)",
+ "CREATE INDEX idx_ctx_ms_user ON ctx_managed_streams(user_id)",
+ "CREATE INDEX idx_ctx_streams_channel ON ctx_streams(channel_id)",
+ "CREATE INDEX idx_ctx_streams_managed ON ctx_streams(managed_stream_id)",
+ "CREATE INDEX idx_ctx_streams_user ON ctx_streams(user_id, id)",
+ "CREATE INDEX idx_ctx_users_display ON ctx_users(display_name COLLATE NOCASE)",
+ "CREATE INDEX idx_ctx_users_subject ON ctx_users(subject_id)",
+ "CREATE INDEX idx_ctx_users_username ON ctx_users(username COLLATE NOCASE)",
+ "CREATE INDEX idx_dm_blocks_blocked ON dm_blocks(blocked_id)",
+ "CREATE INDEX idx_dm_blocks_blocker ON dm_blocks(blocker_id)",
+ "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_participants_conv ON dm_participants(conversation_id)",
+ "CREATE INDEX idx_dm_participants_user ON dm_participants(user_id)",
+ "CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code)",
+ "CREATE INDEX idx_emotes_channel_owner ON emotes(channel_owner_id)",
+ "CREATE INDEX idx_emotes_user ON emotes(user_id)",
+ "CREATE INDEX idx_events_outbox_unsent ON events_outbox(sent_at, seq)",
+ "CREATE INDEX idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform)",
+ "CREATE INDEX idx_mod_actions_actor ON moderation_actions(actor_user_id)",
+ "CREATE INDEX idx_mod_actions_created ON moderation_actions(created_at DESC)",
+ "CREATE INDEX idx_mod_actions_scope ON moderation_actions(scope_type, scope_id)",
+ "CREATE INDEX idx_network_blocks_blocked ON network_blocks(blocked_subject, active)",
+ "CREATE INDEX idx_pending_ip_channel ON pending_ip_messages(channel_id, status)",
+ "CREATE INDEX idx_room_members_user ON room_members(user_id)",
+ "CREATE INDEX idx_room_messages_room ON room_messages(room_id, id)",
+ "CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id)",
+ "CREATE INDEX idx_user_tags_user ON user_tags(user_id)"
+ ],
+ "ledger": {
+ "chat_meta": []
+ },
+ "lazy": [
+ "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 )"
+ ],
+ "statements": [
+ "DELETE FROM audio_requests WHERE state IN ('played', 'skipped', 'failed') AND COALESCE(finished_at, created_at) < ?",
+ "DELETE FROM bridge_applied WHERE applied_at < ?",
+ "DELETE FROM bridge_refs WHERE at < ?",
+ "DELETE FROM channel_moderators WHERE channel_id = ? AND user_id = ?",
+ "DELETE FROM channel_sounds WHERE id = ?",
+ "DELETE FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?",
+ "DELETE FROM dm_messages WHERE id = ?",
+ "DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?",
+ "DELETE FROM emotes WHERE id = ?",
+ "DELETE FROM hidden_relay_users WHERE id = ?",
+ "DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS ?",
+ "DELETE FROM live_mirror_outbox WHERE seq <= ?",
+ "DELETE FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?",
+ "DELETE FROM room_members WHERE room_id = ? AND user_id = ?",
+ "DELETE FROM tts_voice_overrides WHERE identity_key = ?",
+ "DELETE FROM user_tags WHERE user_id = ? AND tag_id = ?",
+ "INSERT INTO audio_requests (room, stream_id, channel_user_id, kind, state, requested_by, identity_key, label, payload, dedupe_key, created_at) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO bridge_applied (key, result, applied_at) VALUES (?, ?, ?)",
+ "INSERT INTO bridge_refs (boot, ref, id, at) VALUES (?, ?, ?, ?) ON CONFLICT(boot, ref) DO UPDATE SET id = excluded.id, at = excluded.at",
+ "INSERT INTO calls (kind, state, channel_id, stream_id, created_by, created_by_subject, target_user_id, target_subject, created_at, started_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO channel_moderation_settings ( channel_id, slow_mode_seconds, followers_only, emote_only, allow_anonymous, links_allowed, gifs_enabled, account_age_gate_hours, caps_percentage_limit, aggressive_filter, max_message_length, slur_filter_enabled, slur_filter_use_builtin, slur_filter_terms, slur_filter_regexes, slur_filter_nudge_message, slur_filter_disabled_categories, ip_approval_mode, soundboard_enabled, soundboard_allow_pitch, soundboard_allow_speed, soundboard_banned_ids, viewer_auto_delete_enabled, viewer_delete_all_enabled, custom_emotes_enabled, custom_sounds_enabled, max_sound_seconds, uploads_mods_only, emote_scale, emote_size_min, emote_size_max, sounds_mods_only, mods_can_edit_about ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO channel_moderation_settings (channel_id) VALUES (?)",
+ "INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code, created_by_subject_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO chat_ai_summaries (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 overview = excluded.overview, memory_json = excluded.memory_json, timeline_json = excluded.timeline_json, message_count = excluded.message_count, window_message_count = excluded.window_message_count, last_message_id = excluded.last_message_id, window_label = excluded.window_label, window_start = excluded.window_start, window_end = excluded.window_end, updated_at = CURRENT_TIMESTAMP",
+ "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ "INSERT INTO ctx_channels (id, user_id, title, synced_at) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET user_id = excluded.user_id, title = excluded.title, synced_at = excluded.synced_at",
+ "INSERT INTO ctx_streams (id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET user_id = excluded.user_id, channel_id = excluded.channel_id, managed_stream_id = excluded.managed_stream_id, title = excluded.title, is_live = excluded.is_live, started_at = excluded.started_at, ended_at = excluded.ended_at, created_at = excluded.created_at, synced_at = excluded.synced_at",
+ "INSERT INTO ctx_users (id, username, display_name, avatar_url, profile_color, role, is_banned, ban_reason, is_owner, created_at, subject_id, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET username = excluded.username, display_name = excluded.display_name, avatar_url = excluded.avatar_url, profile_color = excluded.profile_color, role = excluded.role, is_banned = excluded.is_banned, ban_reason = excluded.ban_reason, is_owner = excluded.is_owner, created_at = excluded.created_at, subject_id = excluded.subject_id, synced_at = excluded.synced_at",
+ "INSERT INTO dm_conversations (name, is_group, created_by) VALUES (?, ?, ?)",
+ "INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id) VALUES (?, ?, ?, ?)",
+ "INSERT INTO emotes (user_id, code, url, animated, width, height, is_global, channel_owner_id, size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO events_outbox (event_id, event_type, event, created_at) VALUES (?, ?, ?, ?)",
+ "INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details, actor_subject_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ "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 WHERE excluded.revision > network_blocks.revision",
+ "INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, user_id, anon_id, username, message) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1) ON CONFLICT(platform, username) DO UPDATE SET last_seen = CURRENT_TIMESTAMP, message_count = message_count + 1, display_name = excluded.display_name",
+ "INSERT INTO room_attachments (room_id, service, resource, title, attached_by, attached_by_subject) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)",
+ "INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)",
+ "INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', CURRENT_TIMESTAMP)",
+ "INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)",
+ "INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO table_authority (table_name, authority, note) VALUES (?, 'live', ?) ON CONFLICT(table_name) DO UPDATE SET note = excluded.note",
+ "INSERT INTO table_authority (table_name, authority, note) VALUES (?, ?, ?) ON CONFLICT(table_name) DO UPDATE SET authority = excluded.authority, note = excluded.note",
+ "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 WHERE excluded.valid_after_ms > token_revocations.valid_after_ms",
+ "INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 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",
+ "INSERT OR IGNORE INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)",
+ "INSERT OR IGNORE INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?)",
+ "INSERT OR IGNORE INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?)",
+ "INSERT OR IGNORE INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?)",
+ "INSERT OR IGNORE INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)",
+ "INSERT OR IGNORE INTO user_tags (user_id, tag_id, source) VALUES (?, ?, ?)",
+ "INSERT OR REPLACE INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by) VALUES (?, ?, ?, ?, ?, ?)",
+ "SELECT * FROM audio_requests WHERE id = ?",
+ "SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state = 'playing'",
+ "SELECT * FROM audio_requests WHERE id = ? AND room = ? AND state IN ('queued', 'playing')",
+ "SELECT * FROM audio_requests WHERE room = ? AND state = 'playing' ORDER BY id LIMIT 1",
+ "SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id",
+ "SELECT * FROM audio_requests WHERE room = ? AND state = 'queued' ORDER BY id LIMIT 1",
+ "SELECT * FROM audio_requests WHERE room = ? AND state IN ('played', 'skipped', 'failed') ORDER BY COALESCE(finished_at, created_at) DESC, id DESC LIMIT ?",
+ "SELECT * FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing') ORDER BY id",
+ "SELECT * FROM calls WHERE id = ?",
+ "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",
+ "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",
+ "SELECT * FROM calls WHERE kind IN ('channel', 'stream') AND channel_id = ? AND state = 'active' ORDER BY id DESC LIMIT 1",
+ "SELECT * FROM channel_moderation_settings WHERE channel_id = ?",
+ "SELECT * FROM channel_moderators WHERE channel_id = ? AND user_id = ?",
+ "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 is_approved = 1 ORDER BY command",
+ "SELECT * FROM channel_sounds WHERE id = ?",
+ "SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND window = ?",
+ "SELECT * FROM chat_messages WHERE id = ?",
+ "SELECT * FROM chat_timeline_events WHERE id = ?",
+ "SELECT * FROM ctx_channels WHERE id = ?",
+ "SELECT * FROM ctx_channels WHERE user_id = ?",
+ "SELECT * FROM ctx_users",
+ "SELECT * FROM ctx_users WHERE id = ?",
+ "SELECT * FROM ctx_users WHERE subject_id = ? LIMIT 2",
+ "SELECT * FROM dm_conversations WHERE id = ?",
+ "SELECT * FROM dm_messages WHERE id = ?",
+ "SELECT * FROM emotes WHERE id = ?",
+ "SELECT * FROM pending_ip_messages WHERE id = ?",
+ "SELECT * FROM room_attachments WHERE room_id = ? AND service = ? AND resource = ?",
+ "SELECT * FROM room_attachments WHERE room_id = ? ORDER BY created_at, service, resource",
+ "SELECT * FROM room_messages WHERE id = ? AND room_id = ?",
+ "SELECT * FROM rooms WHERE id = ?",
+ "SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL",
+ "SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?",
+ "SELECT * FROM user_tags WHERE user_id = ? AND tag_id = ?",
+ "SELECT 1 FROM audio_requests WHERE room = ? AND dedupe_key = ?",
+ "SELECT 1 FROM channel_moderation_settings WHERE channel_id = ?",
+ "SELECT 1 FROM channel_moderators WHERE user_id = ? AND channel_id = ?",
+ "SELECT 1 FROM chat_messages cm JOIN ctx_streams s ON cm.stream_id = s.id WHERE s.channel_id = ? AND cm.is_deleted = 0 AND (cm.user_id = ? OR cm.anon_id = ?) LIMIT 1",
+ "SELECT 1 FROM ctx_streams WHERE id = ?",
+ "SELECT 1 FROM ctx_users WHERE id = ?",
+ "SELECT 1 FROM dm_blocks WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)",
+ "SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?",
+ "SELECT 1 FROM dm_messages WHERE conversation_id = ? AND sender_id = ? AND message = ? AND created_at > datetime('now', '-' || ? || ' seconds') LIMIT 1",
+ "SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?",
+ "SELECT 1 FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND (channel_id = ? OR channel_id IS NULL)",
+ "SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1",
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
+ "SELECT 1 FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?",
+ "SELECT COALESCE(MAX(id), 0) AS m FROM ctx_streams",
+ "SELECT COALESCE(MAX(id), 0) AS m FROM ctx_users",
+ "SELECT COALESCE(SUM(unread), 0) as total FROM ( SELECT COUNT(*) as unread FROM dm_messages m 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 != ? )",
+ "SELECT COUNT(*) AS c FROM chat_messages 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)",
+ "SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND identity_key = ? AND state IN ('queued', 'playing')",
+ "SELECT COUNT(*) AS n FROM audio_requests WHERE room = ? AND state IN ('queued', 'playing')",
+ "SELECT COUNT(*) AS n FROM live_mirror_outbox",
+ "SELECT COUNT(*) AS n FROM live_mirror_outbox WHERE tbl = ?",
+ "SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'",
+ "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 WHERE m.user_id = ? AND m.role != 'blocked'",
+ "SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL",
+ "SELECT COUNT(*) as c FROM chat_messages WHERE user_id = ? AND is_deleted = 0 AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)",
+ "SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?",
+ "SELECT COUNT(*) as cnt FROM chat_messages WHERE is_global = 1 AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0",
+ "SELECT COUNT(*) as cnt FROM chat_messages WHERE stream_id = ? AND timestamp >= ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'",
+ "SELECT COUNT(*) as cnt FROM chat_messages WHERE stream_id = ? AND timestamp >= datetime(?) AND timestamp <= datetime(?) AND is_deleted = 0",
+ "SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?",
+ "SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?",
+ "SELECT DISTINCT room FROM audio_requests WHERE state = 'queued'",
+ "SELECT MAX(id) AS id FROM chat_messages",
+ "SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?",
+ "SELECT MIN(timestamp) AS t FROM chat_messages WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0",
+ "SELECT blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1",
+ "SELECT c.* FROM dm_conversations c 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 = ? WHERE c.is_group = 0",
+ "SELECT c.id, c.name, c.is_group, c.updated_at, (SELECT dm.message FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message, (SELECT dm.sender_id FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_sender_id, (SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message_at, (SELECT COUNT(*) FROM dm_messages dm WHERE dm.conversation_id = c.id AND dm.created_at > p.last_read_at AND dm.sender_id != ?) AS unread_count FROM dm_conversations c JOIN dm_participants p ON p.conversation_id = c.id AND p.user_id = ? ORDER BY COALESCE((SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1), c.created_at) DESC",
+ "SELECT cm.*, s.title as stream_title FROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id WHERE cm.user_id = ? AND cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?",
+ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name FROM chat_messages cm 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' AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)",
+ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name FROM chat_messages cm 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' AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) ORDER BY cm.timestamp ASC",
+ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, COALESCE(su.username, cu.username) AS stream_channel, s.is_live AS source_is_live, s.managed_stream_id AS source_managed_id, COALESCE(ms.title, s.title) AS source_stream_title, ms.slug AS source_slug FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_users su ON s.user_id = su.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE cm.is_deleted = 0 AND cm.message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation') AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) AND cm.id > ? ORDER BY cm.id ASC LIMIT ?",
+ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, COALESCE(su.username, cu.username) AS stream_channel, s.is_live AS source_is_live, s.managed_stream_id AS source_managed_id, COALESCE(ms.title, s.title) AS source_stream_title, ms.slug AS source_slug FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_users su ON s.user_id = su.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE cm.is_deleted = 0 AND cm.message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation') AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) ORDER BY cm.timestamp DESC, cm.id DESC LIMIT ?",
+ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, s.title AS source_stream_title, s.managed_stream_id AS source_managed_id, s.is_live AS source_is_live, ms.slug AS source_slug, COALESCE(bu.username, cu.username) AS source_channel FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id LEFT JOIN ctx_users bu ON s.user_id = bu.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id 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)",
+ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, s.title AS source_stream_title, s.managed_stream_id AS source_managed_id, s.is_live AS source_is_live, ms.slug AS source_slug, COALESCE(bu.username, cu.username) AS source_channel FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id LEFT JOIN ctx_users bu ON s.user_id = bu.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id 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) ORDER BY cm.timestamp DESC LIMIT ?",
+ "SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name, u.username AS core_username, s.title AS source_stream_title, s.managed_stream_id AS source_managed_id, s.is_live AS source_is_live, ms.slug AS source_slug, COALESCE(bu.username, cu.username) AS source_channel FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id LEFT JOIN ctx_streams s ON cm.stream_id = s.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id LEFT JOIN ctx_users bu ON s.user_id = bu.id LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id 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) ORDER BY cm.timestamp DESC, cm.id DESC LIMIT ?",
+ "SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id WHERE cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)",
+ "SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id WHERE cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) AND cm.user_id = ?",
+ "SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color FROM chat_messages cm LEFT JOIN ctx_users u ON cm.user_id = u.id WHERE cm.is_deleted = 0 AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP) AND cm.user_id = ? ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?",
+ "SELECT created_at FROM ctx_users WHERE id = ?",
+ "SELECT created_at FROM room_messages WHERE room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1",
+ "SELECT id FROM bridge_refs WHERE boot = ? AND ref = ?",
+ "SELECT id FROM calls WHERE channel_id = ? AND state = 'active'",
+ "SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND state IN ('pending', 'ringing')",
+ "SELECT id FROM calls WHERE kind = 'direct' AND channel_id = ? AND target_user_id = ? AND state = 'ringing'",
+ "SELECT id FROM channel_moderators WHERE channel_id = ? AND user_id = ?",
+ "SELECT id FROM chat_messages WHERE anon_id = ? AND stream_id = ? AND is_deleted = 0",
+ "SELECT id FROM chat_messages WHERE user_id = ? AND stream_id = ? AND is_deleted = 0",
+ "SELECT id FROM ctx_streams WHERE is_live = 1",
+ "SELECT id FROM ctx_streams WHERE user_id = ? ORDER BY id DESC LIMIT 1",
+ "SELECT id FROM ctx_users WHERE subject_id = ?",
+ "SELECT id FROM emotes WHERE id = ?",
+ "SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id",
+ "SELECT id FROM user_tags WHERE user_id = ? AND tag_id = ?",
+ "SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1",
+ "SELECT id, state FROM calls WHERE state IN ('pending', 'ringing', 'active')",
+ "SELECT id, stream_id FROM chat_messages WHERE is_deleted = 0 AND auto_delete_at IS NOT NULL AND auto_delete_at <= CURRENT_TIMESTAMP ORDER BY auto_delete_at ASC LIMIT ?",
+ "SELECT id, username, display_name, avatar_url, profile_color FROM ctx_users WHERE id != ? AND is_banned = 0 AND id NOT IN (SELECT blocked_id FROM dm_blocks WHERE blocker_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 blocker_subject FROM network_blocks WHERE blocked_subject = ? AND active = 1) AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE) LIMIT ?",
+ "SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_messages m JOIN ctx_users u ON u.id = m.sender_id WHERE m.conversation_id = ? AND m.id < ? ORDER BY m.created_at DESC LIMIT ?",
+ "SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_messages m JOIN ctx_users u ON u.id = m.sender_id WHERE m.conversation_id = ? AND m.id > ? ORDER BY m.created_at ASC LIMIT ?",
+ "SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_messages m JOIN ctx_users u ON u.id = m.sender_id WHERE m.conversation_id = ? ORDER BY m.created_at DESC LIMIT ?",
+ "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",
+ "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 WHERE m.id = ?",
+ "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 WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?",
+ "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 WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?",
+ "SELECT message_id FROM deploy_releases WHERE head = ?",
+ "SELECT metadata FROM chat_messages WHERE id = ?",
+ "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 FROM ctx_managed_streams ms WHERE ms.user_id = ? ORDER BY ms.sort_order ASC, ms.created_at ASC",
+ "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 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 ORDER BY COALESCE(r.last_message_at, r.created_at) DESC",
+ "SELECT result FROM bridge_applied WHERE key = ?",
+ "SELECT role FROM room_members WHERE room_id = ? AND user_id = ?",
+ "SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?",
+ "SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color, ms.slug AS managed_stream_slug, ms.title AS managed_stream_title FROM ctx_streams s LEFT JOIN ctx_users u ON s.user_id = u.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id",
+ "SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color, ms.slug AS managed_stream_slug, ms.title AS managed_stream_title FROM ctx_streams s LEFT JOIN ctx_users u ON s.user_id = u.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE s.id = ?",
+ "SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color, ms.slug AS managed_stream_slug, ms.title AS managed_stream_title FROM ctx_streams s LEFT JOIN ctx_users u ON s.user_id = u.id LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?",
+ "SELECT seq, event_id, event FROM events_outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100",
+ "SELECT seq, tbl, op, pk FROM live_mirror_outbox ORDER BY seq LIMIT ?",
+ "SELECT subject_id FROM ctx_users WHERE id = ?",
+ "SELECT table_name, authority FROM table_authority",
+ "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",
+ "SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color FROM dm_participants p JOIN ctx_users u ON u.id = p.user_id WHERE p.conversation_id = ?",
+ "SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, b.created_at as blocked_at FROM dm_blocks b JOIN ctx_users u ON u.id = b.blocked_id WHERE b.blocker_id = ? ORDER BY b.created_at DESC",
+ "SELECT user_id, subject_id FROM dm_participants WHERE conversation_id = ?",
+ "SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?",
+ "SELECT value FROM chat_meta WHERE key = ?",
+ "SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?",
+ "UPDATE audio_requests SET attempts = attempts + 1 WHERE id = ?",
+ "UPDATE audio_requests SET state = 'failed', finished_at = ?, error = 'expired', actor = 'system' WHERE state = 'queued' AND created_at < ?",
+ "UPDATE audio_requests SET state = 'played', finished_at = ?, error = 'delivered before a restart' WHERE state = 'playing'",
+ "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, finished_at = CASE WHEN ? THEN ? ELSE finished_at END WHERE id = ? AND state IN (?,?)",
+ "UPDATE channel_moderation_settings SET sub_only = ? WHERE channel_id = ?",
+ "UPDATE channel_moderation_settings SET tts_max_length = ? WHERE channel_id = ?",
+ "UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?",
+ "UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?",
+ "UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?",
+ "UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (?)",
+ "UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?",
+ "UPDATE chat_messages SET metadata = ? WHERE id = ?",
+ "UPDATE chat_messages SET username = ? WHERE user_id = ?",
+ "UPDATE ctx_streams SET is_live = 0 WHERE id = ?",
+ "UPDATE ctx_users SET profile_color = ? WHERE id = ?",
+ "UPDATE dm_conversations SET is_group = 1 WHERE id = ?",
+ "UPDATE dm_conversations SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE dm_conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE dm_participants SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = ? AND user_id = ?",
+ "UPDATE emotes SET media_url = ?, media_asset_id = ? WHERE id = ?",
+ "UPDATE events_outbox SET attempts = attempts + 1, last_error = ? WHERE seq = ?",
+ "UPDATE events_outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?",
+ "UPDATE pending_ip_messages SET status = 'approved', 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'",
+ "UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?",
+ "UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?",
+ "UPDATE room_attachments SET title = ? WHERE room_id = ? AND service = ? AND resource = ?",
+ "UPDATE room_members SET last_read_id = ?, last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?",
+ "UPDATE room_members SET last_read_id = MAX(last_read_id, ?), last_seen_at = CURRENT_TIMESTAMP WHERE room_id = ? AND user_id = ?",
+ "UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?",
+ "UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?",
+ "UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ? WHERE id = ?",
+ "UPDATE table_authority SET authority = ? WHERE table_name = ?"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+'use strict';
+/**
+ * N-1 (roadmap WS-P task 11, ADR-016): during a deploy, and for the 24-hour mixed-version window after
+ * it, open tabs run the previous release's client against this release's server, and a previous-release
+ * process can still be working on the database this release migrated. Both must keep working.
+ *
+ * - An N-1 client against the N server: test/fixtures/n-1/client.json is every call the previous
+ * release's client code makes (found statically, made concrete with seeded values) with what that
+ * release answered: status, JSON or not, and the response fields the old client reads with their
+ * types. This checkout boots (test/n-1/service.js) and must answer each call compatibly: any 2xx
+ * for a 2xx, the same status otherwise (a 5xx only needs the route), JSON where there was JSON,
+ * every read field still there with a type it had. The run sits inside openvibe-shared's
+ * mixed-version matrix (release-compat assertMixedVersion) with both releases' manifests.
+ * - N-1 SQL against the N schema: test/fixtures/n-1/worker.json holds the previous release's schema
+ * (and migration ledger, and what it creates on first use) and every statement it ran or has as a
+ * literal. The database this test boots on is created from that schema, so this release's
+ * migrations run over it; afterwards every N-1 statement must still prepare (no table or column it
+ * names is gone), and no N-1 INSERT may miss a column this release made NOT NULL without a default.
+ *
+ * The fixtures come from the release in production: `npm run n-1:record` after each deploy (see
+ * scripts/n-1-record.js). This test needs no git history and no network.
+ *
+ * node test/n-1.test.js
+ */
+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');
+
+const ROOT = path.join(__dirname, '..');
+const FIX = path.join(__dirname, 'fixtures', 'n-1');
+const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));
+const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${String(err.message).split('\n').join('\n ')}`); }
+}
+
+(async () => {
+ const age = Math.round((Date.now() - Date.parse(client.recorded_at)) / 86400000);
+ console.log(`n-1: ${svc.service} N-1 = ${client.release.slice(0, 12)}, recorded ${client.recorded_at.slice(0, 10)} (${age} d ago); ${client.calls.length} calls, ${worker.statements.length} statements`);
+
+ console.log('n-1: the harness');
+ await check('finds call sites, their method and template, and skips computed paths', () => {
+ const files = [{ name: 'a.js', text: "api('/streams'); api(`/users/${encodeURIComponent(u.username)}/follow`, { method: 'POST', body: {} }); fetch(`${API}/api/x?limit=${n}`); api(path); fetch('https://elsewhere/x'); api(`/b/${on ? 'mute' : 'unmute'}`, { method: 'post' });" }];
+ const { calls, dynamic } = h.extractCalls({ files, callers: [{ name: 'api', prefix: '/api' }, { name: 'fetch' }], strip: ['API'] });
+ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.template}`), ['POST /api/b/{on ? \'mute\' : \'unmute\'}', 'GET /api/streams', 'POST /api/users/{encodeURIComponent(u.username)}/follow', 'GET /api/x?limit={n}']);
+ assert.strictEqual(dynamic, 1);
+ const samples = [[/id\)*$/i, '1'], [/name\)*$/i, 'n1star']];
+ assert.deepStrictEqual(calls.map((c) => h.concretePath(c.parts, samples)), ['/api/b/mute', '/api/streams', '/api/users/n1star/follow', '/api/x?limit=']);
+ });
+ await check('reads: the key paths the client names; a gone or retyped one is a problem, an empty list or null parent is not', () => {
+ const ids = h.clientIdentifiers(['x.streams.map((s) => s.title); const { total } = d; s?.viewer_count']);
+ const reads = h.readsOf({ streams: [{ title: 'a', viewer_count: 1, secret: 'k' }], total: 3, other: 1 }, ids);
+ assert.deepStrictEqual(reads, { streams: 'array', 'streams[]': 'object', 'streams[].title': 'string', 'streams[].viewer_count': 'number', total: 'number' });
+ assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: 2 }], total: 1 }), []);
+ assert.deepStrictEqual(h.readProblems(reads, { streams: [], total: 0 }), []);
+ 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();
+ });
+
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-test-'));
+ const dbPath = path.join(tmp, 'db', `${svc.service}.db`);
+ 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]));
+ }
+ }
+ for (const ddl of worker.lazy || []) d.exec(ddl);
+ })();
+ d.pragma(`user_version = ${Number(worker.user_version) || 0}`);
+ d.close();
+ svc.seed({ dir: ROOT, dbPath, dataDir });
+ });
+ server = await svc.boot({ dir: ROOT, dbPath, dataDir });
+ const manifest = await (await fetch(`${server.url}/release.json`)).json();
+
+ console.log('n-1: the N-1 client against this server');
+ const sockets = client.ws ? `, and ${client.ws.sends.length} socket message types in ${client.ws.sessions.length} sessions` : '';
+ await check(`every call the N-1 client makes is answered compatibly (${client.calls.length} requests${sockets}, in openvibe-shared's mixed-version matrix)`, async () => {
+ const broken = [];
+ const n1Client = async (url) => {
+ const probe = await h.notFoundProbe(url);
+ for (const rec of client.calls) {
+ const got = await h.send(url, { method: rec.method, path: h.fillPath(rec.path, server.ids), headers: { ...(rec.accept ? { accept: 'text/html' } : {}), ...server.headers(rec.auth) }, body: rec.method === 'GET' ? undefined : {} });
+ const problems = h.callProblems(rec, got, probe);
+ if (problems.length) broken.push(`${rec.auth} ${rec.method} ${rec.path} (${rec.from.join(', ')}): ${problems.join('; ')}`);
+ }
+ for (const session of (client.ws && client.ws.sessions) || []) {
+ const messages = h.wsMessages(client.ws.sends, { order: svc.ws.order, values: server.wsValues(session.auth) });
+ const received = await h.wsSession({ url: url.replace(/^http/, 'ws') + client.ws.path, headers: svc.ws.headers, messages });
+ broken.push(...h.wsProblems(session.received, received).map((p) => `socket ${client.ws.path} (${session.auth}): ${p}`));
+ }
+ if (broken.length) throw new Error(`${broken.length} broken:\n${h.summarize(broken)}`);
+ };
+ await compat.assertMixedVersion({ releases: [
+ { name: 'N-1', manifest: client.manifest, client: n1Client },
+ { name: 'N', manifest, server: async () => ({ url: server.url }) },
+ ] });
+ });
+ await server.close();
+ 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();
+ 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 {
+ if (server) { console.log(server.log().slice(-1500)); await server.close(); }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+
+ if (failures) { console.log(`\nn-1: ${failures} check(s) failed`); process.exit(1); }
+ console.log('\nn-1: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'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,
+ * and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it (Chat's own shutdown).
+ */
+const path = require('path');
+
+(async () => {
+ const dir = process.cwd();
+ 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 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 });
+ 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');
+ } 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);
+ conversation = c && (c.id || c.conversation_id || c);
+ 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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,855 @@
@@ -0,0 +1,855 @@
+'use strict';
+/**
+ * N-1 harness (roadmap WS-P task 11, ADR-016's mixed-version window): the previous release's client
+ * against this release's server, and the previous release's SQL against this release's schema.
+ * The same file in Live, Chat, Media and Community; each repository's test/n-1/service.js says what
+ * its clients are, how to boot and seed it, and where its SQL lives.
+ *
+ * scripts/n-1-record.js <ref> at release time: checks out <ref> (the release in production, which
+ * is N-1 for the next deploy) into a temporary worktree, boots it, and
+ * writes test/fixtures/n-1/{client,worker}.json
+ * test/n-1.test.js in npm test and CI: boots this checkout on a database created with
+ * N-1's schema and replays the fixture; no git history, no network
+ *
+ * Client calls are found statically in the N-1 client code (the call sites of `api(...)`, `fetch(...)`
+ * and the service's other wrappers, with a literal or template path), made concrete with sample values,
+ * and replayed against the booted N-1 server: every call anonymously, reads again signed in. What the
+ * fixture keeps per call is what an old client depends on: the status, whether the answer is JSON,
+ * and the response fields it reads (the JSON key paths whose every key the N-1 client code names, with
+ * their types).
+ *
+ * SQL is every statement N-1 prepared while it served those calls, plus every literal SQL string in its
+ * server code that prepares on its own schema. The N-1 schema is its sqlite_master after the replay.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+// ── Client calls: static extraction ──────────────────────────
+
+/** Reads a JS string/template literal at src[i] (a quote). → { end, parts: [{ text } | { expr }] } or null. */
+function readLiteral(src, i) {
+ const q = src[i];
+ if (q !== '\'' && q !== '"' && q !== '`') return null;
+ const parts = [];
+ let text = '';
+ let j = i + 1;
+ while (j < src.length) {
+ const c = src[j];
+ if (c === '\\') { text += unescapeChar(src[j + 1]); j += 2; continue; }
+ if (c === q) { if (text) parts.push({ text }); return { end: j + 1, parts }; }
+ if (q !== '`' && c === '\n') return null;
+ if (q === '`' && c === '$' && src[j + 1] === '{') {
+ if (text) parts.push({ text });
+ text = '';
+ const close = skipBalanced(src, j + 1, '{', '}');
+ if (close < 0) return null;
+ parts.push({ expr: src.slice(j + 2, close).trim() });
+ j = close + 1;
+ continue;
+ }
+ text += c;
+ j++;
+ }
+ return null;
+}
+
+function unescapeChar(c) {
+ return { n: '\n', t: '\t', r: '\r', '0': '\0' }[c] ?? c ?? '';
+}
+
+/** Index of the bracket closing the one at src[i] (open), skipping strings and templates; -1 if none. */
+function skipBalanced(src, i, open, close) {
+ let depth = 0;
+ for (let j = i; j < src.length; j++) {
+ const c = src[j];
+ if (c === '\'' || c === '"' || c === '`') {
+ const lit = readLiteral(src, j);
+ if (lit) { j = lit.end - 1; continue; }
+ }
+ if (c === open) depth++;
+ else if (c === close) { depth--; if (depth === 0) return j; }
+ }
+ return -1;
+}
+
+/** Top-level comma-separated arguments of a call whose '(' is at src[i]. */
+function callArgs(src, i) {
+ const end = skipBalanced(src, i, '(', ')');
+ if (end < 0) return null;
+ const args = [];
+ let depth = 0; let start = i + 1;
+ for (let j = i + 1; j < end; j++) {
+ const c = src[j];
+ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }
+ if (c === '(' || c === '{' || c === '[') depth++;
+ else if (c === ')' || c === '}' || c === ']') depth--;
+ else if (c === ',' && depth === 0) { args.push(src.slice(start, j).trim()); start = j + 1; }
+ }
+ const last = src.slice(start, end).trim();
+ if (last) args.push(last);
+ return args;
+}
+
+const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/** src split at every top-level `sep` (outside strings and brackets). */
+function splitTop(src, sep) {
+ const out = [];
+ let depth = 0; let start = 0;
+ for (let j = 0; j < src.length; j++) {
+ const c = src[j];
+ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }
+ if (c === '(' || c === '{' || c === '[') depth++;
+ else if (c === ')' || c === '}' || c === ']') depth--;
+ else if (c === sep && depth === 0) { out.push(src.slice(start, j)); start = j + 1; }
+ }
+ out.push(src.slice(start));
+ return out.map((x) => x.trim());
+}
+
+/**
+ * The parts of a path expression: a literal, a template, or literals, templates and names joined with
+ * `+` ('/api/x/' + id + '?a=1'); a name in `vars` stands for the path it was assigned. → parts or null
+ */
+function pathParts(expr, vars = new Map()) {
+ const parts = [];
+ for (const piece of splitTop(expr, '+')) {
+ if (!piece) return null;
+ const lit = /^['"`]/.test(piece) ? readLiteral(piece, 0) : null;
+ if (lit && lit.end === piece.length) parts.push(...lit.parts.flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x])));
+ else if (vars.has(piece)) parts.push(...vars.get(piece));
+ else parts.push({ expr: piece });
+ }
+ const merged = [];
+ for (const p of parts) {
+ const last = merged[merged.length - 1];
+ if (p.text != null && last && last.text != null) last.text += p.text; else merged.push({ ...p });
+ }
+ return merged;
+}
+
+/** `esc(base)`, `encodeURIComponent(base)`: the name inside one escaping call. */
+function unwrap(expr) {
+ const m = /^(?:esc|enc|encodeURI|encodeURIComponent|String|escapeHtml|escHtml)\(\s*([A-Za-z_$][\w$]*)\s*\)$/.exec(String(expr).trim());
+ return m ? m[1] : String(expr).trim();
+}
+
+/** `var|let|const name = <path expression>` in a file, for names later calls use as a path base. */
+function pathVars(src) {
+ const vars = new Map();
+ for (const m of src.matchAll(/(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*(?=['"`])/g)) {
+ let depth = 0; let end = src.length;
+ for (let j = m.index + m[0].length; j < src.length; j++) {
+ const c = src[j];
+ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }
+ if (c === '(' || c === '{' || c === '[') depth++;
+ else if (c === ')' || c === '}' || c === ']') { if (depth === 0) { end = j; break; } depth--; } else if (depth === 0 && (c === ';' || c === '\n' || c === ',')) { end = j; break; }
+ }
+ const parts = pathParts(src.slice(m.index + m[0].length, end).trim(), vars);
+ if (parts && parts[0] && parts[0].text && parts[0].text.startsWith('/') && !parts[0].text.startsWith('//')) vars.set(m[1], parts);
+ }
+ return vars;
+}
+
+/**
+ * Every call site of the service's request functions whose path is a literal, a template or a
+ * concatenation starting with one (or with a variable assigned one).
+ * callers: [{ name: 'api', prefix: '/api', method?: 'POST', methodArg?: 1 }]
+ * strip: template expressions dropped when a path starts with them (e.g. 'API', 'location.origin')
+ * keep(pathname, method, file): whether a call is this server's
+ * → { calls: [{ method, template, parts, from: [file] }], dynamic: calls with a computed path }
+ */
+function extractCalls({ files, callers, strip = [], keep = (p) => true, forms = false }) {
+ const found = new Map();
+ let dynamic = 0;
+ for (const { name: file, text: src } of files) {
+ const vars = pathVars(src);
+ for (const caller of callers) {
+ const re = new RegExp(`(?<![\\w$.])${esc(caller.name)}\\s*\\(`, 'g');
+ let m;
+ while ((m = re.exec(src))) {
+ const open = m.index + m[0].length - 1;
+ const args = callArgs(src, open);
+ if (!args || !args.length) continue;
+ // An SDK-style call: call({ method: 'POST', path: `/api/x/${id}`, … }).
+ let pathExpr = args[0];
+ let objMethod = null;
+ if (caller.object) {
+ if (!/^\{[\s\S]*\}$/.test(args[0])) continue; // some other function of that name
+ const entries = splitTop(args[0].slice(1, -1), ',');
+ const entry = (k) => { const e = entries.find((x) => new RegExp(`^${k}\\s*:`).test(x)); return e ? e.replace(new RegExp(`^${k}\\s*:\\s*`), '') : null; };
+ pathExpr = entry('path');
+ const me = entry('method');
+ const ml = me && /^['"`]/.test(me) ? readLiteral(me, 0) : null;
+ if (ml && ml.parts.length === 1 && ml.parts[0].text) objMethod = ml.parts[0].text;
+ if (!pathExpr) continue;
+ }
+ let parts = pathParts(pathExpr, vars);
+ if (!parts) { dynamic++; continue; }
+ while (parts.length && parts[0].expr && strip.includes(parts[0].expr)) parts.shift();
+ if (!parts.length || !parts[0].text || !parts[0].text.startsWith('/') || parts[0].text.startsWith('//')) {
+ if (parts.length && parts[0].expr) dynamic++;
+ continue;
+ }
+ parts = caller.prefix ? [{ text: caller.prefix }, ...parts] : parts;
+ if (parts[1] && parts[0].text != null && parts[1].text != null) parts = [{ text: parts[0].text + parts[1].text }, ...parts.slice(2)];
+ // `/api${path}`: a wrapper passing a path through, not a call.
+ if (parts.some((p, k) => p.expr && k > 0 && parts[k - 1].text != null && !parts.slice(0, k).some((x) => x.text && x.text.includes('?')) && /[A-Za-z0-9]$/.test(parts[k - 1].text))) { dynamic++; continue; }
+ const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('').replace(/^([^?#]*)\/\//, '$1/');
+ let method = objMethod || caller.method || 'GET';
+ if (caller.object) { /* the method was in the object */ } else if (caller.methodArg != null) {
+ const a = args[caller.methodArg];
+ const ml = a && /^['"`]/.test(a) ? readLiteral(a, 0) : null;
+ if (ml && ml.parts.length === 1 && ml.parts[0].text) method = ml.parts[0].text;
+ } else {
+ const opts = args.slice(1).join(',');
+ const mm = /\bmethod\s*:\s*['"`]([A-Za-z]+)['"`]/.exec(opts);
+ if (mm) method = mm[1];
+ }
+ method = method.toUpperCase();
+ if (!keep(template.split('?')[0], method, file)) continue;
+ const key = `${method} ${template}`;
+ if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });
+ found.get(key).from.add(file);
+ }
+ }
+ }
+ // Forms the pages post (or get) to: <form action="/x/${id}" method="post"> in markup or a template.
+ if (forms) {
+ for (const { name: file, text: src } of files) {
+ const vars = pathVars(src);
+ for (const m of src.matchAll(/<form\b/gi)) {
+ const end = tagEnd(src, m.index);
+ if (end < 0) continue;
+ const tag = src.slice(m.index, end);
+ const action = attr(tag, 'action');
+ if (!action || action.startsWith('//') || /^[a-z]+:/i.test(action)) continue;
+ const parts = templateParts(action).flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x]));
+ if (!parts.length || parts[0].text == null || !parts[0].text.startsWith('/')) continue;
+ const methodAttr = attr(tag, 'method');
+ const method = /^post$/i.test(methodAttr || '') ? 'POST' : 'GET';
+ const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('');
+ if (!keep(template.split('?')[0], method, file)) continue;
+ const key = `${method} ${template}`;
+ if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });
+ found.get(key).from.add(file);
+ }
+ }
+ }
+ const calls = [...found.values()].map((c) => ({ ...c, from: [...c.from].sort() }))
+ .sort((a, b) => (a.template < b.template ? -1 : a.template > b.template ? 1 : a.method < b.method ? -1 : 1));
+ return { calls, dynamic };
+}
+
+/** Where an HTML tag that starts at src[i] ends ('>' outside quotes and ${…}); -1 if it does not. */
+function tagEnd(src, i) {
+ let q = null;
+ for (let j = i; j < src.length; j++) {
+ const c = src[j];
+ if (c === '$' && src[j + 1] === '{') { const close = skipBalanced(src, j + 1, '{', '}'); if (close < 0) return -1; j = close; continue; }
+ if (q) { if (c === q) q = null; continue; }
+ if (c === '"' || c === '\'') q = c;
+ else if (c === '>') return j + 1;
+ }
+ return -1;
+}
+
+/** An attribute's raw value in a tag (quoted, may hold ${…}). */
+function attr(tag, name) {
+ const m = new RegExp(`\\s${name}\\s*=\\s*(["'])`, 'i').exec(tag);
+ if (!m) return null;
+ const q = m[1];
+ let out = '';
+ for (let j = m.index + m[0].length; j < tag.length; j++) {
+ const c = tag[j];
+ if (c === '$' && tag[j + 1] === '{') { const close = skipBalanced(tag, j + 1, '{', '}'); if (close < 0) return null; out += tag.slice(j, close + 1); j = close; continue; }
+ if (c === q) return out;
+ out += c;
+ }
+ return null;
+}
+
+/** Text with ${expr} holes → parts. */
+function templateParts(text) {
+ const parts = [];
+ let buf = '';
+ for (let j = 0; j < text.length; j++) {
+ if (text[j] === '$' && text[j + 1] === '{') {
+ const close = skipBalanced(text, j + 1, '{', '}');
+ if (close < 0) break;
+ if (buf) parts.push({ text: buf });
+ buf = '';
+ parts.push({ expr: text.slice(j + 2, close).trim() });
+ j = close;
+ continue;
+ }
+ buf += text[j];
+ }
+ if (buf) parts.push({ text: buf.replace(/&/g, '&') });
+ return parts;
+}
+
+/**
+ * A concrete path for a template: each ${expr} becomes the first sample whose pattern matches the
+ * expression text, else '1' in the path and '' in the query.
+ */
+function concretePath(parts, samples = []) {
+ let out = '';
+ for (const p of parts) {
+ if (p.text != null) { out += p.text; continue; }
+ const inQuery = out.includes('?');
+ // `${on ? 'mute' : 'unmute'}`: the first branch.
+ const branch = /\?\s*(['"`])([^'"`]*)\1\s*:/.exec(p.expr);
+ if (branch) { out += branch[2]; continue; }
+ const hit = samples.find(([re]) => re.test(p.expr));
+ let v = hit ? hit[1] : (inQuery ? '' : '1');
+ if (typeof v === 'function') v = v(p.expr, inQuery);
+ // '@name': a value the booted server seeded (a random slug); filled in when the call is sent.
+ if (/^@\w+$/.test(String(v))) { out += `{${v}}`; continue; }
+ out += inQuery ? String(v) : encodeURIComponent(String(v)).replace(/%2F/gi, '/');
+ }
+ out = out.replace(/^([^?#]*)\/\/+/, '$1/').replace(/[?&]+$/, '').replace(/\?&+/, '?');
+ return out;
+}
+
+/**
+ * The same-origin URLs a served page makes the browser fetch or offers to follow: src, href, poster,
+ * data-src and form actions (with their method). `origins` are absolute origins that count as this
+ * site (e.g. its public URL). → [{ method, path }]
+ */
+function pageLinks(html, origins = []) {
+ const out = new Map();
+ const add = (method, raw) => {
+ let u = String(raw || '').trim().replace(/&/g, '&');
+ if (!u || /^(#|mailto:|javascript:|data:|tel:|blob:)/i.test(u)) return;
+ const o = origins.find((x) => u.startsWith(`${x}/`) || u === x);
+ if (o) u = u.slice(o.length) || '/';
+ if (!u.startsWith('/') || u.startsWith('//')) return;
+ u = u.split('#')[0];
+ out.set(`${method} ${u}`, { method, path: u });
+ };
+ for (const m of String(html).matchAll(/<([a-zA-Z][\w-]*)\b([^>]*)>/g)) {
+ const tag = m[1].toLowerCase(); const attrs = m[2];
+ const get = (n) => { const a = new RegExp(`\\s${n}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs); return a ? (a[1] != null ? a[1] : a[2]) : null; };
+ if (tag === 'form') { const a = get('action'); if (a != null) add(/^post$/i.test(get('method') || '') ? 'POST' : 'GET', a); continue; }
+ for (const n of ['src', 'href', 'poster', 'data-src']) { const v = get(n); if (v != null) add('GET', v); }
+ }
+ return [...out.values()];
+}
+
+/** A path with the seeded ids that differ per run (random slugs, keys) put back as {@name} slots. */
+function unfillPath(p, ids = {}) {
+ let out = String(p);
+ for (const [name, v] of Object.entries(ids || {})) {
+ const s = v == null ? '' : String(v);
+ if (s.length < 6 || /^\d+$/.test(s)) continue;
+ out = out.split(encodeURIComponent(s)).join(`{@${name}}`).split(s).join(`{@${name}}`);
+ }
+ return out;
+}
+
+/** A recorded path with its {@name} slots filled from the booted server's seeded ids. */
+function fillPath(p, ids = {}) {
+ return String(p).replace(/\{@(\w+)\}/g, (m, name) => encodeURIComponent(ids[name] == null ? '0' : String(ids[name])));
+}
+
+/** Property names the client code reads: `.name`, `?.name`, `['name']`, 'name' literals, destructured names. */
+function clientIdentifiers(texts) {
+ const ids = new Set();
+ for (const t of texts) {
+ for (const m of t.matchAll(/\??\.\s*([A-Za-z_$][\w$]*)/g)) ids.add(m[1]);
+ for (const m of t.matchAll(/['"`]([A-Za-z_$][\w$]*)['"`]/g)) ids.add(m[1]);
+ for (const m of t.matchAll(/\{([^{}()]*)\}\s*(?:=[^=>]|\)\s*=>|\s*\))/g)) {
+ for (const part of m[1].split(',')) {
+ const id = /^\s*(?:\.\.\.)?\s*([A-Za-z_$][\w$]*)/.exec(part);
+ if (id) ids.add(id[1]);
+ }
+ }
+ }
+ return ids;
+}
+
+// ── Response shapes ──────────────────────────────────────────
+
+function typeOf(v) {
+ if (v === null) return 'null';
+ if (Array.isArray(v)) return 'array';
+ return typeof v;
+}
+
+/**
+ * The key paths of a JSON value → { 'a': 'object', 'a.b': 'string', 'list[]': 'object', 'list[].x': 'number' }.
+ * Array elements are merged: a path is present when any element has it; its types are all it takes.
+ */
+function flatten(value, maxDepth = 5) {
+ const out = {};
+ const add = (p, t) => { if (!out[p]) out[p] = new Set(); out[p].add(t); };
+ const walk = (v, prefix, depth) => {
+ if (depth > maxDepth) return;
+ if (Array.isArray(v)) {
+ for (const el of v) { add(`${prefix}[]`, typeOf(el)); walk(el, `${prefix}[]`, depth + 1); }
+ } else if (v && typeof v === 'object') {
+ for (const [k, x] of Object.entries(v)) {
+ const p = prefix ? `${prefix}.${k}` : k;
+ add(p, typeOf(x));
+ walk(x, p, depth + 1);
+ }
+ }
+ };
+ walk(value, '', 0);
+ return out;
+}
+
+/** The key paths of `value` an old client reads: every key along the path is one the client names. */
+function readsOf(value, ids) {
+ const flat = flatten(value);
+ const reads = {};
+ for (const [p, types] of Object.entries(flat)) {
+ const keys = p.split('.').map((k) => k.replace(/\[\]$/g, '')).filter(Boolean);
+ if (!keys.length || !keys.every((k) => ids.has(k))) continue;
+ const t = [...types].filter((x) => x !== 'null').sort();
+ reads[p] = t.length ? t.join('|') : 'null';
+ }
+ return reads;
+}
+
+/** What `reads` (recorded from N-1) finds missing or retyped in `value` (from N). */
+function readProblems(reads, value) {
+ const flat = flatten(value);
+ const problems = [];
+ // A path under an array N answers empty (or null) cannot be read by the old client either.
+ const emptyArray = (p) => {
+ const segs = p.split('.');
+ for (let i = 1; i <= segs.length; i++) {
+ const head = segs.slice(0, i).join('.');
+ if (!head.endsWith('[]')) continue;
+ const arr = head.slice(0, -2);
+ if (!flat[head] && (arr === '' ? Array.isArray(value) : flat[arr] && !flat[arr].has('object'))) return true;
+ }
+ return false;
+ };
+ const nullParent = (p) => {
+ const segs = p.split('.');
+ for (let i = 1; i < segs.length; i++) {
+ const head = segs.slice(0, i).join('.').replace(/\[\]$/, '');
+ if (flat[head] && [...flat[head]].every((t) => t === 'null')) return true;
+ }
+ return false;
+ };
+ for (const [p, want] of Object.entries(reads)) {
+ if (emptyArray(p) || nullParent(p)) continue;
+ const have = flat[p];
+ if (!have) { problems.push(`${p} is gone`); continue; }
+ if (want === 'null') continue;
+ const now = [...have].filter((t) => t !== 'null');
+ if (!now.length) continue;
+ const was = want.split('|');
+ if (!now.some((t) => was.includes(t))) problems.push(`${p} was ${want}, now ${now.join('|')}`);
+ }
+ return problems;
+}
+
+/** Whether N's status keeps N-1's promise: any 2xx for a 2xx, the same code otherwise; a 5xx only needs the route. */
+function statusCompatible(was, now) {
+ if (was >= 200 && was < 300) return now >= 200 && now < 300;
+ if (was >= 500) return now !== 404 && now !== 405;
+ return was === now;
+}
+
+// ── Replaying calls ──────────────────────────────────────────
+
+function kindOf(contentType, text) {
+ const ct = String(contentType || '').toLowerCase();
+ if (ct.includes('text/event-stream')) return 'sse';
+ if (ct.includes('json')) return 'json';
+ if (ct.includes('html')) return 'html';
+ if (!text) return 'empty';
+ if (ct.startsWith('text/')) return 'text';
+ return 'other';
+}
+
+/**
+ * Sends one call. → { status, kind, json, location }. Redirects are not followed (they may leave the
+ * sandbox); an event stream is not read past its headers.
+ */
+async function send(baseUrl, { method, path: p, headers = {}, body }, { timeoutMs = 8000 } = {}) {
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
+ try {
+ const hasBody = body !== undefined && method !== 'GET' && method !== 'HEAD';
+ const res = await fetch(new URL(p, baseUrl), {
+ method,
+ redirect: 'manual',
+ signal: ac.signal,
+ headers: { accept: 'application/json', ...(hasBody ? { 'content-type': 'application/json' } : {}), ...headers },
+ body: hasBody ? JSON.stringify(body) : undefined,
+ });
+ const ct = res.headers.get('content-type');
+ if (/event-stream/i.test(ct || '')) { ac.abort(); return { status: res.status, kind: 'sse', json: null }; }
+ const text = await res.text();
+ const kind = kindOf(ct, text);
+ let json = null;
+ if (kind === 'json') { try { json = JSON.parse(text); } catch { json = null; } }
+ const html = kind === 'html' ? text.slice(0, 1 << 20) : null;
+ const loc = res.headers.get('location');
+ // Where a redirect goes, without its query (a sign-in `next` or `state` differs every run).
+ return { status: res.status, kind, json, html, location: loc ? loc.replace(/^https?:\/\/[^/]+/, '').split('?')[0] : null };
+ } catch (err) {
+ return { status: 0, kind: 'error', json: null, error: err.name === 'AbortError' ? 'timeout' : err.message };
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/**
+ * The request list for a set of extracted calls: every call anonymously, GETs also signed in.
+ * Reads first, then writes (with an empty JSON body), so a write cannot change what a read sees.
+ */
+function requestsFor(calls, samples, { signedIn = true } = {}) {
+ const seen = new Set();
+ const reqs = [];
+ for (const c of calls) {
+ const p = concretePath(c.parts, samples);
+ for (const auth of c.method === 'GET' && signedIn ? ['anon', 'user'] : ['anon']) {
+ const key = `${c.method} ${p} ${auth}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ reqs.push({ method: c.method, path: p, auth, from: c.from });
+ }
+ }
+ const rank = (r) => (r.method === 'GET' ? 0 : 1);
+ return reqs.sort((a, b) => rank(a) - rank(b));
+}
+
+/**
+ * What the server answers a path nobody routes (per method): an HTML 404 is always "no route"; a
+ * JSON catch-all 404 (`{ error: 'Not found' }`) is recognised by its body. → [{ method, status, kind, json }]
+ */
+async function notFoundProbe(baseUrl, headers = {}) {
+ const out = [];
+ for (const method of ['GET', 'POST']) {
+ for (const p of ['/api/n-1-no-such-route', '/n-1-no-such-page']) {
+ const got = await send(baseUrl, { method, path: p, headers, body: method === 'GET' ? undefined : {} });
+ if (got.status === 404) out.push({ method, status: got.status, kind: got.kind, json: got.json });
+ }
+ }
+ return out;
+}
+
+/** Whether an answer is the server's unknown-path 404 rather than a route's own. */
+function isNoRoute(got, probe = []) {
+ if (got.status !== 404) return false;
+ if (got.kind !== 'json') return true;
+ return probe.some((fp) => fp.kind === 'json' && JSON.stringify(fp.json) === JSON.stringify(got.json));
+}
+
+/** The failures of one replayed call against its recorded outcome (probe: N's unknown-path answers). */
+function callProblems(rec, got, probe = []) {
+ const problems = [];
+ if (got.status === 0) return [`no answer (${got.error})`];
+ if (isNoRoute(got, probe)) return ['the route is gone (it answers as an unknown path)'];
+ if (!statusCompatible(rec.status, got.status)) problems.push(`answered ${got.status}, N-1 answered ${rec.status}`);
+ else if (rec.kind === 'json' && got.kind !== 'json' && rec.status < 500) problems.push(`answered ${got.kind}, N-1 answered JSON`);
+ if (rec.location && got.location && rec.status >= 300 && rec.status < 400 && got.location.split('?')[0] !== rec.location.split('?')[0]) problems.push(`redirects to ${got.location}, N-1 to ${rec.location}`);
+ if (rec.reads && got.json != null && got.status >= 200 && got.status < 300) problems.push(...readProblems(rec.reads, got.json));
+ return problems;
+}
+
+// ── WebSockets ───────────────────────────────────────────────
+
+/**
+ * The messages a client sends over its sockets: `x.send(JSON.stringify({ type: 'join', … }))` and
+ * `helper({ type: … })` for each helper name. → [{ type, keys: [...] }] (keys merged per type)
+ */
+function wsSends(files, helpers = []) {
+ const byType = new Map();
+ const add = (obj) => {
+ const t = obj.trim();
+ if (!t.startsWith('{') || !t.endsWith('}')) return;
+ let type = null; const keys = [];
+ for (const entry of splitTop(t.slice(1, -1), ',')) {
+ if (!entry || entry.startsWith('...')) continue;
+ const kv = /^(?:['"]?)([A-Za-z_$][\w$-]*)(?:['"]?)\s*(?::\s*([\s\S]*))?$/.exec(entry);
+ if (!kv) continue;
+ if (kv[1] === 'type') {
+ const lit = kv[2] && /^['"`]/.test(kv[2].trim()) ? readLiteral(kv[2].trim(), 0) : null;
+ if (lit && lit.parts.length === 1 && lit.parts[0].text) type = lit.parts[0].text;
+ } else keys.push(kv[1]);
+ }
+ if (!type) return;
+ if (!byType.has(type)) byType.set(type, new Set());
+ for (const k of keys) byType.get(type).add(k);
+ };
+ for (const { text } of files) {
+ for (const m of text.matchAll(/\.send\(\s*JSON\.stringify\(/g)) {
+ const args = callArgs(text, m.index + m[0].length - 1);
+ if (args && args[0]) add(args[0]);
+ }
+ for (const name of helpers) {
+ for (const m of text.matchAll(new RegExp(`(?<![\\w$.])${esc(name)}\\s*\\(\\s*(?=\\{)`, 'g'))) {
+ const args = callArgs(text, m.index + m[0].indexOf('('));
+ if (args && args[0]) add(args[0]);
+ }
+ }
+ }
+ return [...byType].map(([type, keys]) => ({ type, keys: [...keys].sort() })).sort((a, b) => (a.type < b.type ? -1 : 1));
+}
+
+/** The message types a client acts on: `case 'x':` and `.type === 'x'`. */
+function wsHandled(files) {
+ const types = new Set();
+ for (const { text } of files) {
+ for (const m of text.matchAll(/\bcase\s+['"]([\w:.-]+)['"]\s*:/g)) types.add(m[1]);
+ for (const m of text.matchAll(/\.type\s*[!=]==?\s*['"]([\w:.-]+)['"]/g)) types.add(m[1]);
+ }
+ return types;
+}
+
+/**
+ * One scripted socket session: connect to `url`, send each message in turn and wait until the server
+ * has been quiet for `quietMs`. → every message received (parsed JSON objects)
+ */
+async function wsSession({ url, headers = {}, messages, quietMs = 250, maxMs = 3000 }) {
+ const WebSocket = require('ws');
+ const ws = new WebSocket(url, { headers });
+ const got = [];
+ let last = Date.now();
+ ws.on('message', (d) => { try { const m = JSON.parse(String(d)); if (m && typeof m === 'object') got.push(m); } catch { /* not JSON */ } last = Date.now(); });
+ await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); ws.once('unexpected-response', (req, res) => reject(new Error(`upgrade refused: ${res.statusCode}`))); });
+ const settle = async () => {
+ const start = Date.now();
+ last = Date.now();
+ while (Date.now() - last < quietMs && Date.now() - start < maxMs) await new Promise((r) => setTimeout(r, 25));
+ };
+ await settle();
+ for (const m of messages) {
+ if (ws.readyState !== 1) break;
+ ws.send(JSON.stringify(m));
+ await settle();
+ }
+ await new Promise((resolve) => { ws.once('close', resolve); ws.close(); setTimeout(resolve, 500); });
+ return got;
+}
+
+/** The messages to send for a client's sends, in `order` (then the rest), with keys filled from `values`. */
+function wsMessages(sends, { order = [], values = {} } = {}) {
+ const rank = (t) => { const i = order.indexOf(t); return i < 0 ? order.length : i; };
+ return sends.slice().sort((a, b) => rank(a.type) - rank(b.type) || (a.type < b.type ? -1 : 1))
+ .map((s) => {
+ const m = { type: s.type };
+ for (const k of s.keys) if (values[k] !== undefined) m[k] = values[k];
+ return m;
+ });
+}
+
+/** Received messages grouped by type, for the types the client handles → { type: reads } */
+function wsReads(received, handled, ids) {
+ const byType = new Map();
+ for (const m of received) {
+ if (typeof m.type !== 'string' || !handled.has(m.type)) continue;
+ if (!byType.has(m.type)) byType.set(m.type, []);
+ byType.get(m.type).push(m);
+ }
+ const out = {};
+ for (const [t, list] of [...byType].sort((a, b) => (a[0] < b[0] ? -1 : 1))) out[t] = readsOf(list, ids);
+ return out;
+}
+
+/** What `recorded` ({ type: reads }) finds missing in the messages N sent. */
+function wsProblems(recorded, received) {
+ const problems = [];
+ for (const [type, reads] of Object.entries(recorded)) {
+ const list = received.filter((m) => m.type === type);
+ if (!list.length) { problems.push(`no '${type}' message any more`); continue; }
+ problems.push(...readProblems(reads, list).map((p) => `'${type}': ${p.replace(/^\[\]\.?/, '')}`));
+ }
+ return problems;
+}
+
+// ── SQL ──────────────────────────────────────────────────────
+
+const SQL_START = /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i;
+
+/** Every plain JS string literal in the files (a template only without ${}), comments skipped. */
+function stringLiterals(files) {
+ const out = [];
+ for (const { text } of files) {
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i];
+ if (c === '/' && text[i + 1] === '/') { const nl = text.indexOf('\n', i); i = nl < 0 ? text.length : nl; continue; }
+ if (c === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; continue; }
+ if (c !== '\'' && c !== '"' && c !== '`') continue;
+ const lit = readLiteral(text, i);
+ if (!lit) continue;
+ i = lit.end - 1;
+ if (lit.parts.length === 1 && lit.parts[0].text != null) out.push(lit.parts[0].text);
+ }
+ }
+ return out;
+}
+
+/** The literals that read like one SQL statement (DML). */
+function sqlLiterals(files) {
+ const out = new Set();
+ for (const t of stringLiterals(files)) {
+ const sql = normalizeSql(t);
+ if (SQL_START.test(sql) && /\b(FROM|INTO|SET|VALUES)\b/i.test(sql)) out.add(sql);
+ }
+ return [...out];
+}
+
+/** SQL without its comments (-- to the end of a line, and block comments), outside quoted text. */
+function stripSqlComments(sql) {
+ let out = '';
+ const s = String(sql);
+ for (let i = 0; i < s.length; i++) {
+ const c = s[i];
+ if (c === '\'' || c === '"') {
+ const j = s.indexOf(c, i + 1);
+ const end = j < 0 ? s.length : j + 1;
+ out += s.slice(i, end); i = end - 1; continue;
+ }
+ if (c === '-' && s[i + 1] === '-') { const nl = s.indexOf('\n', i); i = nl < 0 ? s.length : nl - 1; continue; }
+ if (c === '/' && s[i + 1] === '*') { const e = s.indexOf('*/', i + 2); i = e < 0 ? s.length : e + 1; out += ' '; continue; }
+ out += c;
+ }
+ return out;
+}
+
+function normalizeSql(sql) {
+ return stripSqlComments(sql).replace(/\s+/g, ' ').trim();
+}
+
+/**
+ * The DDL N-1 runs itself when it first needs a table or column (a module's CREATE TABLE IF NOT EXISTS,
+ * a boot-time ALTER TABLE … ADD COLUMN) that `db` does not have yet. Each statement is applied to `db`
+ * as it is found to apply; → the applied statements, tables first, in order.
+ */
+function lazyDDL(files, db) {
+ const tables = () => new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
+ const columns = (t) => { try { return new Set(db.prepare(`PRAGMA table_xinfo("${t}")`).all().map((c) => c.name.toLowerCase())); } catch { return new Set(); } };
+ const lits = stringLiterals(files).map(stripSqlComments);
+ const applied = [];
+ const apply = (ddl) => { try { db.exec(ddl); applied.push(ddl); return true; } catch { return false; } };
+ for (const text of lits) {
+ for (const m of text.matchAll(/CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+["`]?(\w+)["`]?\s*\(/gi)) {
+ // A rebuild's scratch table (x_new) is the migration's own business, not a table N-1 uses.
+ if (tables().has(m[1]) || /_(new|old|tmp|backup)$/i.test(m[1])) continue;
+ const end = skipBalanced(text, m.index + m[0].length - 1, '(', ')');
+ if (end >= 0) apply(normalizeSql(text.slice(m.index, end + 1)));
+ }
+ }
+ for (const text of lits) {
+ const m = /^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+(?:COLUMN\s+)?["`]?(\w+)["`]?[^;]*$/i.exec(text.trim().replace(/;$/, ''));
+ if (!m || !tables().has(m[1]) || columns(m[1]).has(m[2].toLowerCase())) continue;
+ apply(normalizeSql(text).replace(/;$/, ''));
+ }
+ return applied;
+}
+
+/** Every file under dir with one of the extensions, as { name (relative to root), text }. */
+function readTree(root, dirs, exts = ['.js']) {
+ const out = [];
+ const walk = (d) => {
+ let entries = [];
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
+ for (const e of entries) {
+ const p = path.join(d, e.name);
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && !e.name.startsWith('.')) walk(p); } else if (exts.some((x) => e.name.endsWith(x))) out.push({ name: path.relative(root, p), text: fs.readFileSync(p, 'utf8') });
+ }
+ };
+ for (const d of dirs) {
+ const abs = path.join(root, d);
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push({ name: d, text: fs.readFileSync(abs, 'utf8') });
+ else walk(abs);
+ }
+ return out.sort((a, b) => (a.name < b.name ? -1 : 1));
+}
+
+/** The schema as DDL, tables first (no internal or FTS shadow tables), then indexes, triggers and views. */
+function schemaDDL(db) {
+ const shadow = new Set(db.prepare('PRAGMA table_list').all().filter((t) => t.type === 'shadow').map((t) => t.name));
+ const rows = db.prepare("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'").all()
+ .filter((r) => !shadow.has(r.name));
+ const order = { table: 0, index: 1, view: 2, trigger: 3 };
+ return rows.sort((a, b) => order[a.type] - order[b.type] || (a.name < b.name ? -1 : 1)).map((r) => r.sql);
+}
+
+/** Prepares every statement on db (a missing table or column fails the prepare). → [{ sql, error }] */
+function prepareProblems(db, statements) {
+ const problems = [];
+ for (const sql of statements) {
+ try { db.prepare(sql); } catch (e) { problems.push({ sql, error: e.message }); }
+ }
+ return problems;
+}
+
+/**
+ * INSERTs of N-1 that N would refuse: a column N made NOT NULL without a default that the INSERT does
+ * not name. → [{ sql, error }]
+ */
+function insertProblems(db, statements) {
+ const problems = [];
+ const cols = new Map();
+ const required = (table) => {
+ if (!cols.has(table)) {
+ let info = [];
+ try { info = db.prepare(`PRAGMA table_xinfo("${table.replace(/"/g, '""')}")`).all(); } catch { info = []; }
+ const pkInt = info.filter((c) => c.pk).length === 1 && info.find((c) => c.pk && /^INTEGER$/i.test(c.type));
+ cols.set(table, info.filter((c) => c.notnull && c.dflt_value == null && !c.hidden && !(pkInt && c.pk)).map((c) => c.name));
+ }
+ return cols.get(table);
+ };
+ for (const sql of statements) {
+ const m = /^\s*(?:INSERT|REPLACE)(?:\s+OR\s+\w+)?\s+INTO\s+["`]?(\w+)["`]?\s*\(([^)]*)\)\s*(VALUES|SELECT)/i.exec(sql);
+ if (!m) continue;
+ const named = new Set(m[2].split(',').map((c) => c.trim().replace(/^["`]|["`]$/g, '').toLowerCase()));
+ const missing = required(m[1]).filter((c) => !named.has(c.toLowerCase()));
+ if (missing.length) problems.push({ sql, error: `${m[1]}.${missing.join(', ')} is NOT NULL without a default, and N-1 does not set it` });
+ }
+ return problems;
+}
+
+// ── Git ──────────────────────────────────────────────────────
+
+/** A detached worktree of `ref` in a temp directory sharing root's node_modules → { dir, sha, remove() }. */
+function worktree(root, ref) {
+ const sha = execFileSync('git', ['-C', root, 'rev-parse', '--verify', `${ref}^{commit}`], { encoding: 'utf8' }).trim();
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-'));
+ const dir = path.join(tmp, 'release');
+ execFileSync('git', ['-C', root, 'worktree', 'add', '--detach', dir, sha], { stdio: 'ignore' });
+ fs.symlinkSync(path.join(root, 'node_modules'), path.join(dir, 'node_modules'), 'dir');
+ return {
+ dir, sha,
+ remove() {
+ try { execFileSync('git', ['-C', root, 'worktree', 'remove', '--force', dir], { stdio: 'ignore' }); } catch { /* */ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ },
+ };
+}
+
+/** Files of another repository at a commit (git show), e.g. Live's chat widget for Chat. → [{ name, text }] */
+function gitFiles(repo, ref, dirs, exts = ['.js']) {
+ const names = execFileSync('git', ['-C', repo, 'ls-tree', '-r', '--name-only', ref, '--', ...dirs], { encoding: 'utf8', maxBuffer: 64 << 20 })
+ .split('\n').filter((n) => n && exts.some((x) => n.endsWith(x)) && !n.includes('node_modules/'));
+ return names.sort().map((n) => ({ name: `${path.basename(repo)}:${n}`, text: execFileSync('git', ['-C', repo, 'show', `${ref}:${n}`], { encoding: 'utf8', maxBuffer: 64 << 20 }) }));
+}
+
+// ── Reporting ────────────────────────────────────────────────
+
+function summarize(problems, max = 40) {
+ const lines = problems.slice(0, max);
+ if (problems.length > max) lines.push(`… and ${problems.length - max} more`);
+ return lines.join('\n');
+}
+
+module.exports = {
+ readLiteral, callArgs, splitTop, pathParts, extractCalls, concretePath, fillPath, unfillPath, pageLinks, clientIdentifiers,
+ flatten, readsOf, readProblems, statusCompatible,
+ send, requestsFor, notFoundProbe, isNoRoute, callProblems,
+ wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
+ stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
+ worktree, gitFiles, summarize,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+/**
+ * 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.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const out = process.env.N1_SQL_OUT;
+if (out) {
+ const Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3'));
+ const seen = new Set();
+ const note = (db, sql) => {
+ try { if (process.env.CHAT_DB_PATH && path.resolve(db.name) === path.resolve(process.env.CHAT_DB_PATH)) seen.add(String(sql)); } catch { /* */ }
+ };
+ const prepare = Database.prototype.prepare;
+ Database.prototype.prepare = function (sql, ...rest) { note(this, sql); return prepare.call(this, sql, ...rest); };
+ const exec = Database.prototype.exec;
+ Database.prototype.exec = function (sql, ...rest) { note(this, sql); return exec.call(this, sql, ...rest); };
+ process.on('exit', () => { try { fs.writeFileSync(out, JSON.stringify([...seen])); } catch { /* */ } });
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * Chat's side of the N-1 harness (test/n-1/harness.js): its clients, how a release boots and is
+ * seeded, where its SQL lives. Used by scripts/n-1-record.js (on N-1, in a temporary worktree) and by
+ * test/n-1.test.js (on this checkout), so both boot and seed the same way.
+ *
+ * Chat's clients are its own pages' scripts (public/web, server/web), the openvibe-sdk chat client as
+ * that release installed it, the links, scripts and forms of the openvibe.chat pages it served, and
+ * OpenVibe.Live's chat widget, messenger, emote and sound pickers: every Live page call that
+ * openvibe.live's nginx sends to Chat (/api/chat, /api/dm, /api/tts, /api/sounds) and the /ws/chat
+ * messages Live's chat sends. At record
+ * time Live's files come from the Live release in production: N1_LIVE_REPO (default ../OpenVibe.Live)
+ * at N1_LIVE_REF (default its HEAD); the fixture names that commit.
+ *
+ * A release boots with its own test/helpers.js (stub Network and Live, Chat in-process) in a child
+ * process (test/n-1/boot-child.js), which also seeds it.
+ */
+const fs = require('fs');
+const path = require('path');
+const { spawn, execFileSync } = require('child_process');
+const { readTree, gitFiles } = require('./harness');
+
+const ROOT = path.join(__dirname, '..', '..');
+const PRELOAD = path.join(__dirname, 'preload.js');
+const CHILD = path.join(__dirname, 'boot-child.js');
+const CHAT_PATHS = /^\/(api\/(chat|dm|tts)\/|api\/sounds(\/|$))/;
+const LIVE_REPO = path.resolve(ROOT, process.env.N1_LIVE_REPO || '../OpenVibe.Live');
+let liveRef = null;
+
+function baseEnv(extra) {
+ return { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR || '/tmp', NODE_ENV: 'test', ...extra };
+}
+
+module.exports = {
+ service: 'chat',
+
+ /** Chat's pages and Live's chat surfaces (record time only: Live comes from its repository). */
+ clientFiles(dir) {
+ liveRef = execFileSync('git', ['-C', LIVE_REPO, 'rev-parse', '--verify', `${process.env.N1_LIVE_REF || 'HEAD'}^{commit}`], { encoding: 'utf8' }).trim();
+ return [
+ ...readTree(dir, ['public/web', 'server/web', 'node_modules/openvibe-sdk/src/chat.js'], ['.js']),
+ ...gitFiles(LIVE_REPO, liveRef, ['public/js', 'public/fragments', 'public/obs', 'public/index.html', 'public/popout-chat.html', 'public/kiosk.html'], ['.js', '.html']),
+ ];
+ },
+ clientReleases: () => ({ live: liveRef }),
+ callers: [
+ { name: 'api', prefix: '/api' },
+ { name: 'apiSWR', prefix: '/api' },
+ { name: 'dmApi', prefix: '/api/dm' },
+ { name: 'fetch' },
+ { name: 'navigator.sendBeacon', method: 'POST' },
+ { name: 'call', object: true },
+ ],
+ strip: ['API', 'location.origin', 'window.location.origin'],
+ /** Live's calls to the paths nginx sends to Chat; everything Chat's own pages call or link to. */
+ keep: (pathname, method, file) => !String(file || '').startsWith('OpenVibe.Live:') || CHAT_PATHS.test(pathname),
+ /** openvibe.chat's pages an open tab may hold (anonymous): their links, scripts and forms are replayed too. */
+ crawl: ['/', '/rooms', '/r/n1-room', '/messages', '/settings', '/updates'],
+ origins: ['https://openvibe.chat'],
+ /** Values for template expressions, first match wins (the ids boot-child.js seeds). */
+ samples: [
+ [/stream/i, '500'],
+ [/room/i, 'n1-room'],
+ [/conv/i, '1'],
+ [/message_?id|dataset\.id/i, '1'],
+ [/channel_?user_?id|chatChannelUserId/i, '1001'],
+ [/\.id\)*$|uid\)*$|user_?id\)*$/i, '1002'],
+ [/after|before|latest|newest|oldest|cursor/i, '0'],
+ [/user_?name/i, 'n1fan'],
+ [/provider/i, 'tenor'],
+ [/kind/i, 'follow'],
+ [/^q$|query/i, 'n1'],
+ ],
+ ws: {
+ path: '/ws/chat?stream=500',
+ // The chat sockets' clients (Live's other pages open /ws/broadcast, /ws/control and /ws/call).
+ files: (name) => /(public\/js\/chat\.js|popout-chat\.html|obs\/chat\.html|public\/web\/chat\.js)$/.test(name),
+ headers: { origin: 'https://openvibe.live', 'cf-connecting-ip': '203.0.113.10' },
+ order: ['join', 'join_stream', 'get-users', 'chat', 'join_room', 'room_message', 'leave_room', 'leave_stream', 'self-delete-history'],
+ // Messages that tick on a clock rather than answer the client.
+ ignore: [],
+ },
+
+ sqlDirs: ['server'],
+ ledgerTables: ['chat_meta'],
+
+ /** Chat migrates when it boots (server/index.js start → initDb); nothing to do first. */
+ seed() {},
+
+ /** Boots the release in `dir` on dbPath → { url, headers(auth), wsValues(auth), close() }. */
+ async boot({ dir, dbPath, sqlOut = '' }) {
+ const child = spawn(process.execPath, ['-r', PRELOAD, CHILD], {
+ cwd: dir,
+ env: baseEnv({ N1_DB: dbPath, N1_SQL_OUT: sqlOut }),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let log = '';
+ let info = null;
+ const ready = new Promise((resolve, reject) => {
+ child.stdout.on('data', (c) => {
+ log = (log + c).slice(-20000);
+ const line = String(c).split('\n').find((l) => l.startsWith('{"n1":'));
+ if (line && !info) { info = JSON.parse(line).n1; resolve(); }
+ });
+ child.on('exit', () => reject(new Error(`the release in ${dir} exited while booting:\n${log.slice(-3000)}`)));
+ });
+ child.stderr.on('data', (c) => { log = (log + c).slice(-20000); });
+ const exited = new Promise((resolve) => child.on('exit', resolve));
+ const timer = setTimeout(() => child.kill('SIGKILL'), 60000);
+ await ready;
+ clearTimeout(timer);
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+ return {
+ url: info.url,
+ ids: info.ids,
+ log: () => log,
+ headers: (auth) => (auth === 'user' ? { authorization: `Bearer ${info.token}` } : {}),
+ wsValues: (auth) => ({
+ streamId: info.ids.stream, channelUserId: info.ids.star, room: info.ids.room || 'n1-room',
+ message: 'hello from the N-1 client', token: auth === 'user' ? info.token : undefined,
+ }),
+ async close() {
+ if (child.exitCode == null) child.kill('SIGTERM');
+ const t = setTimeout(() => { if (child.exitCode == null) child.kill('SIGKILL'); }, 8000);
+ await exited;
+ clearTimeout(t);
+ },
+ };
+ },
+};