openvibers/openvibe.live Commits
N-1 harness, the version Chat, Media and Community share (roadmap WS-P task 11): test/n-1/harness.js, scripts/n-1-record.js and test/n-1.test.js are now the same file in all four repositories; only test/n-1/service.js (clients, boot, seed, samples) differs. Added for the other services and used here: pages an open tab holds are crawled at record time (Live: the shell and /@n1star, 63 more requests: every hashed script and stylesheet, links and forms, which the next release must still answer); paths built by concatenation or from a variable ('/api/x/' + id, base + '/y'); SDK-style call({ method, path }) sites; <form> actions; seeded ids that differ per run ({@name} slots); a JSON catch-all 404 counts as "the route is gone"; /ws/* sessions (sent message types from the client code, the replies' types and read fields), used by Chat; redirects compared without their query. Fixtures re-recorded from 8caa540, the release in production: 478 requests, 964 statements. docs/deploy.md
Changes
@@@ -131,9 +131,11 @@ server, and a previous-release process may still be working on the database the`test/n-1.test.js` (in `npm test`, so in CI) checks both from fixtures recorded from the release inproduction:--- `test/fixtures/n-1/client.json`: every call the previous release's client code makes, with the-- status, JSON-ness and the response fields it reads. This checkout boots in the drill sandbox on a-- database created with the previous schema, answers each call compatibly, and keeps every read field.++- `test/fixtures/n-1/client.json`: every call the previous release's client code makes, and every++ script, stylesheet and link of the shell and a channel page it served, with the status, JSON-ness++ and the response fields the client reads. This checkout boots in the drill sandbox (writes let++ through) on a database created with the previous schema, answers each call compatibly, and keeps++ every read field. The calls nginx sends to OpenVibe.Chat are Chat's N-1 test.- `test/fixtures/n-1/worker.json`: the previous schema, migration ledger and every SQL statement thatrelease ran or has as a literal. After this release's migrations each must still prepare, and noold INSERT may miss a new NOT NULL column.@@@ -10,7 +10,8 @@** 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. Writes test/fixtures/n-1/client.json and worker.json; commit them.++ * 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');@@@ -37,7 +38,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');// 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 });++ 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)`);@@@ -45,15 +46,31 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');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 h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false })) {-- const got = await h.send(server.url, { method: req.method, path: req.path, headers: server.headers(req.auth), body: req.method === 'GET' ? undefined : {} });++ 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 (got.status === 404 && got.kind !== 'json') { noRoute++; continue; }-- const rec = { method: req.method, path: req.path, auth: req.auth, status: got.status, kind: got.kind };++ 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);@@@ -62,10 +79,24 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');rec.from = req.from;recorded.push(rec);}-- const extra = svc.recordExtra ? await svc.recordExtra({ server, dir: wt.dir, files, ids }) : null;++ 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;-- console.log(` replayed: ${recorded.length} requests (left out: ${unanswered} unanswered, ${noRoute} with no route on N-1)`);// 3. The schema N-1 left, and the SQL it runs on it.const db = new Database(dbPath);@@@ -87,7 +118,8 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');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' };-- fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, manifest, dynamic_calls: dynamic, calls: recorded, ...(extra ? { extra } : {}) }, null, 1)}\n`);++ 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 {@@@ -96,4 +128,3 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');fs.rmSync(tmp, { recursive: true, force: true });}})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });--@@@ -1,13 +1,13 @@{"service": "live","release": "8caa540466f9af86247febdbba1339b68919a42f",-- "recorded_at": "2026-09-26T13:44:31.818Z",++ "recorded_at": "2026-09-26T14:07:22.139Z","note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js","manifest": {"service": "live","release": "8caa540466f9","released_at": "2026-09-26T13:22:32.000Z",-- "booted_at": "2026-09-26T13:44:29.263Z",++ "booted_at": "2026-09-26T14:07:19.453Z","contracts_version": "0.56.0","packages": {"openvibe-shared": "1.22.0",@@@ -31,7 +31,7 @@"contract_ranges": {},"metrics_url": "/release-metrics"},-- "dynamic_calls": 49,++ "dynamic_calls": 44,"calls": [{"method": "GET",@@@ -1212,6 +1212,34 @@"public/js/app-channel.js"]},++ {++ "method": "GET",++ "path": "/api/chat-ai/timeline",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "events": "array",++ "hasMore": "boolean"++ },++ "from": [++ "public/js/app-chatpage.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/chat-ai/timeline",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "events": "array",++ "hasMore": "boolean"++ },++ "from": [++ "public/js/app-chatpage.js"++ ]++ },{"method": "GET","path": "/api/chat-ai/transcript/1",@@@ -1371,6 +1399,26 @@"public/js/stream-player.js"]},++ {++ "method": "GET",++ "path": "/api/clips/1/job?job=1",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "public/js/ov-clip-jobs.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/clips/1/job?job=1",++ "auth": "user",++ "status": 400,++ "kind": "json",++ "from": [++ "public/js/ov-clip-jobs.js"++ ]++ },{"method": "GET","path": "/api/clips?limit=5&offset=0&auto_generated=0",@@@ -1606,48 +1654,48 @@},{"method": "GET",-- "path": "/api/controls/presets",++ "path": "/api/controls/configs/1/bridge-script?type=generic","auth": "anon",-- "status": 200,++ "status": 401,"kind": "json",-- "reads": {-- "controls": "array",-- "settings": "object"-- },"from": ["public/js/dashboard.js"]},{"method": "GET",-- "path": "/api/controls/presets",++ "path": "/api/controls/configs/1/bridge-script?type=generic","auth": "user",-- "status": 200,++ "status": 404,"kind": "json",-- "reads": {-- "controls": "array",-- "settings": "object"-- },"from": ["public/js/dashboard.js"]},{"method": "GET",-- "path": "/api/controls/presets/1",++ "path": "/api/controls/presets","auth": "anon",-- "status": 404,++ "status": 200,"kind": "json",++ "reads": {++ "controls": "array",++ "settings": "object"++ },"from": ["public/js/dashboard.js"]},{"method": "GET",-- "path": "/api/controls/presets/1",++ "path": "/api/controls/presets","auth": "user",-- "status": 404,++ "status": 200,"kind": "json",++ "reads": {++ "controls": "array",++ "settings": "object"++ },"from": ["public/js/dashboard.js"]@@@ -6815,6 +6863,705 @@"public/js/app-media.js"]},++ {++ "method": "GET",++ "path": "/",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page"++ ]++ },++ {++ "method": "GET",++ "path": "/assets/logo.svg",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/manifest.webmanifest",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "json",++ "reads": {++ "name": "string",++ "description": "string",++ "scope": "string",++ "display": "string"++ },++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/assets/logo-192.png",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/style.css?v=a3843ec6bdd6",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/features/home.css?v=52249fa8d30a",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/home-fx.css?v=8f135af68e76",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/home-tour.css?v=3f1293159aff",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/i18n-star.css?v=3a854948d72f",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/vendor/fontawesome-6.5.1.min.css?v=d6c948f648fd",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/chat-shared.css?v=558d74cf2e6a",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/broadcast.css?v=942804f7c8a1",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/cosmetics.css?v=193dbe9046fb",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/messenger.css?v=8d3312c1f857",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/content",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/moments",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/chat",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/documentation",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/@n1star",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/tos",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/privacy",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/dmca",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/updates",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/content?type=vods",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/content?type=clips",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/content?type=pastes",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/live-notify.js",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/ov-skeleton.js?v=1e6a011f824c",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/ov-loader.js?v=d87608436703",++ "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": "/js/app.js?v=2b3a4e2cc1c9",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/ov-navbar-live.js?v=54bcbe2d01d1",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/chat-shared.js?v=8fff7cb6e502",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/chat.js?v=a35bbba644ad",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/vibes.js?v=aef505d77f34",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/opencoins.js?v=2e5a908601ff",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/draggable-fab.js?v=fd94fe405afb",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/messenger.js?v=d1d537f2289d",++ "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": "/js/footer-live.js?v=60c31729accf",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/theme-loader.js?v=d9b9af081efe",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/ov-display-menu.js?v=cb6be0b1998e",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/notification-ui.js?v=a889be53ea64",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/ov-rail.js?v=57c8cd1aca1e",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/ov-live-realtime.js?v=e82b1b7a273a",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/app-home.js?v=d88ec40a35c0",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/ov-odometer.js?v=3f6dee017f2f",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/ov-density.js?v=fc239b8c3986",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/home-fx.js?v=b69411bb61ba",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/home-tour.js?v=a5ded0950973",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/tooltip.js?v=096488cf4948",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/css/features/channel.css?v=64174155ceda",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/css/context-cards.css?v=60ad33ae3e48",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/ov-clip-jobs.js?v=b674bd0b77b1",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/stream-player.js?v=cd0a5b166216",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/stream-pip.js?v=11ae18077afa",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/controls.js?v=04c5242890ee",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/context-cards.js?v=6719bbc1c432",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/emotes.js?v=0099deffc3c0",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/gif-picker.js?v=5ee269f4e9c1",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/chat-autocomplete.js?v=b726f226680f",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/channel-uploads.js?v=03eb0f258adf",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },++ {++ "method": "GET",++ "path": "/js/app-channel.js?v=747045747200",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /@n1star"++ ]++ },{"method": "PUT","path": "/api/admin/users/1",@@@ -1,7 +1,7 @@{"service": "live","release": "8caa540466f9af86247febdbba1339b68919a42f",-- "recorded_at": "2026-09-26T13:44:31.818Z",++ "recorded_at": "2026-09-26T14:07:22.139Z","note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js","user_version": 0,"schema": [@@@ -82,6 +82,7 @@"CREATE TABLE stream_controls (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n label TEXT NOT NULL,\n command TEXT NOT NULL, -- command string sent to hardware\n icon TEXT DEFAULT 'fa-gamepad', -- Font Awesome icon class\n control_type TEXT DEFAULT 'button' CHECK(control_type IN ('button', 'toggle', 'slider', 'dpad', 'onvif', 'keyboard')),\n key_binding TEXT, -- keyboard shortcut\n cooldown_ms INTEGER DEFAULT 100,\n is_enabled INTEGER DEFAULT 1,\n sort_order INTEGER DEFAULT 0,\n camera_id INTEGER, -- NULL for non-ONVIF controls; references camera_profiles(id)\n onvif_movement TEXT, -- 'pan_left', 'pan_right', 'tilt_up', 'tilt_down', 'zoom_in', 'zoom_out', 'preset'\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, btn_color TEXT DEFAULT '', btn_bg TEXT DEFAULT '', btn_border_color TEXT DEFAULT '',\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,\n FOREIGN KEY (camera_id) REFERENCES camera_profiles(id) ON DELETE SET NULL\n)","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 stream_memories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n offset_seconds INTEGER DEFAULT 0, -- seconds into the stream when captured\n captured_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n description TEXT,\n tags TEXT, -- JSON array\n thumbnail_url TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, transcript_json TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )",++ "CREATE TABLE stream_recaps (\n stream_id INTEGER PRIMARY KEY,\n user_id INTEGER,\n json TEXT NOT NULL,\n ai INTEGER DEFAULT 0,\n announced_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )","CREATE TABLE stream_timeline_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n vod_id INTEGER, -- set once the session becomes a VOD\n kind TEXT NOT NULL, -- 'speech' | 'sound'\n start_sec REAL NOT NULL, -- absolute seconds into the stream\n end_sec REAL,\n text TEXT, -- speech content\n label TEXT, -- sound event label\n confidence REAL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, lang TEXT, text_en TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )","CREATE TABLE streamer_overviews (\n user_id INTEGER PRIMARY KEY,\n overview TEXT,\n model TEXT,\n sources TEXT,\n generated_at DATETIME DEFAULT CURRENT_TIMESTAMP, overview_short TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )","CREATE TABLE streams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n channel_id INTEGER,\n managed_stream_id INTEGER,\n control_config_id INTEGER,\n title TEXT DEFAULT 'Untitled Stream',\n description TEXT DEFAULT '',\n category TEXT DEFAULT 'irl',\n tags TEXT DEFAULT '[]',\n protocol TEXT DEFAULT 'webrtc' CHECK(protocol IN ('jsmpeg', 'webrtc', 'rtmp')),\n is_live INTEGER DEFAULT 0,\n is_nsfw INTEGER DEFAULT 0,\n viewer_count INTEGER DEFAULT 0,\n peak_viewers INTEGER DEFAULT 0,\n follower_count INTEGER DEFAULT 0,\n thumbnail_url TEXT,\n multi_cam INTEGER DEFAULT 0,\n started_at DATETIME,\n ended_at DATETIME,\n last_heartbeat DATETIME,\n duration_seconds INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ai_overview TEXT, ai_title TEXT, ai_category TEXT, ai_tags TEXT, ai_overview_short TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,\n FOREIGN KEY (managed_stream_id) REFERENCES managed_streams(id) ON DELETE SET NULL,\n FOREIGN KEY (control_config_id) REFERENCES control_configs(id) ON DELETE SET NULL\n)",@@@ -212,6 +213,7 @@"CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id)","CREATE UNIQUE INDEX idx_stream_memories_moment_unique ON stream_memories(stream_id, offset_seconds)","CREATE INDEX idx_stream_memories_stream ON stream_memories(stream_id, offset_seconds)",++ "CREATE INDEX idx_stream_recaps_user ON stream_recaps(user_id, created_at)","CREATE INDEX idx_streams_channel_id ON streams(channel_id)","CREATE INDEX idx_streams_created ON streams(created_at)","CREATE INDEX idx_streams_is_live ON streams(is_live)",@@@ -255,19 +257,19 @@"schema_migrations": [{"id": "001_vibes_decimal_to_bits",-- "applied_at": "2026-09-26 13:44:27",++ "applied_at": "2026-09-26 14:07:18","mode": "adopted","duration_ms": 0},{"id": "002_pastes_ai_columns",-- "applied_at": "2026-09-26 13:44:27",++ "applied_at": "2026-09-26 14:07:18","mode": "applied","duration_ms": 6},{"id": "004_hot_path_indexes",-- "applied_at": "2026-09-26 13:44:27",++ "applied_at": "2026-09-26 14:07:18","mode": "applied","duration_ms": 1}@@@ -286,7 +288,6 @@"CREATE TABLE IF NOT EXISTS search_media_pushes ( kind TEXT NOT NULL, media_id INTEGER NOT NULL, hash TEXT NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (kind, media_id) )","CREATE TABLE IF NOT EXISTS billing_actions ( id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, live_user_id INTEGER, live_ref TEXT, method TEXT NOT NULL, path TEXT NOT NULL, request_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', http_status INTEGER, billing_ref TEXT, response_json TEXT, error TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )","CREATE TABLE IF NOT EXISTS opencoin_admin_grants ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_key TEXT UNIQUE, admin_id INTEGER, user_id INTEGER NOT NULL, amount INTEGER NOT NULL, reason TEXT, balance INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )",-- "CREATE TABLE IF NOT EXISTS stream_recaps ( stream_id INTEGER PRIMARY KEY, user_id INTEGER, json TEXT NOT NULL, ai INTEGER DEFAULT 0, announced_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )","CREATE TABLE IF NOT EXISTS tips_deliveries ( idempotency_key TEXT PRIMARY KEY, effect TEXT, state TEXT NOT NULL DEFAULT 'pending', response_json TEXT, claimed_at INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )","ALTER TABLE users ADD COLUMN theme_id INTEGER","ALTER TABLE streams ADD COLUMN call_mode TEXT DEFAULT NULL"@@@ -748,6 +749,7 @@"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ?","SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at >= ?","SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?",++ "SELECT COUNT(*) AS n FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE r.ai = 1 AND COALESCE(u.is_banned, 0) = 0","SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) LIKE '%laugh%' OR LOWER(label) LIKE '%giggle%' OR LOWER(label) LIKE '%chuckle%') AND created_at >= datetime('now', ?)","SELECT COUNT(*) AS n FROM streams WHERE is_live = 1","SELECT COUNT(*) AS n FROM streams WHERE started_at >= ?",@@@ -921,6 +923,7 @@"SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?) AND LENGTH(text) BETWEEN 25 AND 220 ORDER BY created_at DESC LIMIT 1500","SELECT id, title, slug FROM managed_streams WHERE user_id = ? AND (? IS NULL OR id != ?) ORDER BY sort_order ASC, id ASC","SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1",++ "SELECT id, ts, label, detail FROM chat_timeline_events WHERE scope = ? AND subject_id = ? ORDER BY ts DESC, id DESC LIMIT ?","SELECT id, user_id FROM channels WHERE id = ?","SELECT id, user_id, anon_id, username, message, message_type, source_platform, reply_to_id, timestamp FROM chat_messages WHERE channel_user_id = ? AND id > ? AND is_deleted = 0 AND message_type IN ('chat','donation') AND (auto_delete_at IS NULL OR auto_delete_at > CURRENT_TIMESTAMP) ORDER BY id DESC LIMIT ?","SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams",@@@ -983,6 +986,7 @@"SELECT r.rowid AS id, r.platform, r.username, r.display_name, r.message_count, r.last_seen, cs.updated_at AS last_update FROM relay_users r LEFT JOIN chat_ai_summaries cs ON cs.scope = 'relay' AND cs.window = 'rolling' AND cs.subject_id = r.rowid WHERE r.last_seen >= ? AND r.message_count >= ? AND (cs.updated_at IS NULL OR cs.updated_at < r.last_seen) ORDER BY (cs.updated_at IS NULL) DESC, r.last_seen DESC LIMIT ?","SELECT r.stream_id FROM stream_recaps r JOIN streams s ON s.id = r.stream_id WHERE r.user_id = ? AND s.started_at >= ? ORDER BY r.created_at DESC LIMIT 1","SELECT r.stream_id, r.json, r.created_at, u.username, u.display_name, u.avatar_url, u.profile_color FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE (? IS NULL OR r.user_id != ?) AND COALESCE(u.is_banned, 0) = 0 ORDER BY r.created_at DESC LIMIT ?",++ "SELECT r.stream_id, r.user_id, r.json, r.created_at FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE r.ai = 1 AND COALESCE(u.is_banned, 0) = 0 ORDER BY r.created_at DESC, r.stream_id DESC LIMIT ? OFFSET ?","SELECT recorded_at AS t, viewer_count AS v, COALESCE(chat_messages_5m, 0) AS c FROM viewer_snapshots WHERE stream_id = ? ORDER BY recorded_at ASC","SELECT revision FROM subject_projection WHERE subject_id = ?","SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?",@@@ -99,15 +99,21 @@ async function check(name, fn) {const manifest = await (await fetch(`${server.url}/release.json`)).json();console.log('n-1: the N-1 client against this server');-- await check(`every call the N-1 client makes is answered compatibly (${client.calls.length} requests, in openvibe-shared's mixed-version matrix)`, async () => {++ 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: rec.path, headers: server.headers(rec.auth), body: rec.method === 'GET' ? undefined : {} });-- const problems = h.callProblems(rec, got);++ 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('; ')}`);}-- if (svc.checkExtra && client.extra) broken.push(...await svc.checkExtra({ server, url, extra: client.extra }));++ 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: [@@@ -94,16 +94,78 @@ function callArgs(src, i) {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());++}++/**-- * Every call site of the service's request functions with a literal or template path.++ * 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')-- * → [{ method, template, parts, from: [file] }], plus { dynamic } calls with no literal path++ * 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 }) {++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;@@@ -111,20 +173,33 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {const open = m.index + m[0].length - 1;const args = callArgs(src, open);if (!args || !args.length) continue;-- const lit = /^['"`]/.test(args[0]) ? readLiteral(args[0], 0) : null;-- if (!lit || lit.end !== args[0].length) { dynamic++; continue; }-- let parts = lit.parts.slice();++ // 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 = caller.method || 'GET';-- if (caller.methodArg != null) {++ 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;@@@ -134,7 +209,29 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {if (mm) method = mm[1];}method = method.toUpperCase();-- if (!keep(template.split('?')[0], method)) continue;++ 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);@@@ -146,6 +243,54 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {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.@@@ -161,12 +306,55 @@ function concretePath(parts, samples = []) {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();@@@ -304,8 +492,10 @@ async function send(baseUrl, { method, path: p, headers = {}, body }, { timeoutMconst 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');-- return { status: res.status, kind, json, location: loc ? loc.replace(/^https?:\/\/[^/]+/, '') : null };++ // 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 {@@@ -333,10 +523,33 @@ function requestsFor(calls, samples, { signedIn = true } = {}) {return reqs.sort((a, b) => rank(a) - rank(b));}--/** The failures of one replayed call against its recorded outcome. */--function callProblems(rec, got) {++/**++ * 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}`);@@@ -344,6 +557,117 @@ function callProblems(rec, got) {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;@@@ -506,6 +830,13 @@ function worktree(root, ref) {};}++/** 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) {@@@ -515,9 +846,10 @@ function summarize(problems, max = 40) {}module.exports = {-- readLiteral, callArgs, extractCalls, concretePath, clientIdentifiers,++ readLiteral, callArgs, splitTop, pathParts, extractCalls, concretePath, fillPath, unfillPath, pageLinks, clientIdentifiers,flatten, readsOf, readProblems, statusCompatible,-- send, requestsFor, callProblems,++ send, requestsFor, notFoundProbe, isNoRoute, callProblems,++ wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,-- worktree, summarize,++ worktree, gitFiles, summarize,};@@@ -69,6 +69,9 @@ module.exports = {],strip: ['API', 'location.origin', 'window.location.origin'],keep: (pathname) => !CHAT_PATHS.test(pathname),++ /** Pages an open tab may hold: the shell (its scripts, styles and links) and a server-rendered channel. */++ crawl: ['/', '/@n1star'],++ origins: ['https://openvibe.live'],/** Values for template expressions, first match wins: the seeded streamer, ids of seeded rows. */samples: [[/id\)*$/i, '1'],
@@ -131,9 +131,11 @@ server, and a previous-release process may still be working on the database the
@@ -131,9 +131,11 @@ server, and a previous-release process may still be working on the database the
`test/n-1.test.js` (in `npm test`, so in CI) checks both from fixtures recorded from the release in
`test/n-1.test.js` (in `npm test`, so in CI) checks both from fixtures recorded from the release in
production:
production:
-- `test/fixtures/n-1/client.json`: every call the previous release's client code makes, with the
- status, JSON-ness and the response fields it reads. This checkout boots in the drill sandbox on a
- database created with the previous schema, answers each call compatibly, and keeps every read field.
+- `test/fixtures/n-1/client.json`: every call the previous release's client code makes, and every
+ script, stylesheet and link of the shell and a channel page it served, with the status, JSON-ness
+ and the response fields the client reads. This checkout boots in the drill sandbox (writes let
+ through) on a database created with the previous schema, answers each call compatibly, and keeps
+ every read field. The calls nginx sends to OpenVibe.Chat are Chat's N-1 test.
- `test/fixtures/n-1/worker.json`: the previous schema, migration ledger and every SQL statement that
- `test/fixtures/n-1/worker.json`: the previous schema, migration ledger and every SQL statement that
release ran or has as a literal. After this release's migrations each must still prepare, and no
release ran or has as a literal. After this release's migrations each must still prepare, and no
old INSERT may miss a new NOT NULL column.
old INSERT may miss a new NOT NULL column.
@@ -10,7 +10,8 @@
@@ -10,7 +10,8 @@
*
*
* The release is checked out into a temporary git worktree (sharing node_modules), seeded and booted
* 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
* 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. Writes test/fixtures/n-1/client.json and worker.json; commit them.
+ * 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 fs = require('fs');
const os = require('os');
const os = require('os');
@@ -37,7 +38,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -37,7 +38,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
// 1. The client: its calls, and the names it reads.
// 1. The client: its calls, and the names it reads.
const files = svc.clientFiles(wt.dir);
const files = svc.clientFiles(wt.dir);
- const { calls, dynamic } = h.extractCalls({ files, callers: svc.callers, strip: svc.strip, keep: svc.keep });
+ 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));
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)`);
console.log(` client: ${files.length} files, ${calls.length} call sites with a path (${dynamic} with a computed one, not replayed)`);
@@ -45,15 +46,31 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -45,15 +46,31 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
await svc.seed({ dir: wt.dir, dbPath, dataDir });
await svc.seed({ dir: wt.dir, dbPath, dataDir });
server = await svc.boot({ dir: wt.dir, dbPath, dataDir, sqlOut });
server = await svc.boot({ dir: wt.dir, dbPath, dataDir, sqlOut });
const manifest = await (await fetch(`${server.url}/release.json`)).json();
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 = [];
const recorded = [];
let unanswered = 0; let noRoute = 0;
let unanswered = 0; let noRoute = 0;
- for (const req of h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false })) {
- const got = await h.send(server.url, { method: req.method, path: req.path, headers: server.headers(req.auth), body: req.method === 'GET' ? undefined : {} });
+ 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; }
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):
// 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.
// nothing N has to keep.
- if (got.status === 404 && got.kind !== 'json') { noRoute++; continue; }
- const rec = { method: req.method, path: req.path, auth: req.auth, status: got.status, kind: got.kind };
+ 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.location) rec.location = got.location;
if (got.json != null && got.status >= 200 && got.status < 300) {
if (got.json != null && got.status >= 200 && got.status < 300) {
const reads = h.readsOf(got.json, ids);
const reads = h.readsOf(got.json, ids);
@@ -62,10 +79,24 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -62,10 +79,24 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
rec.from = req.from;
rec.from = req.from;
recorded.push(rec);
recorded.push(rec);
}
}
- const extra = svc.recordExtra ? await svc.recordExtra({ server, dir: wt.dir, files, ids }) : null;
+ 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();
await server.close();
server = null;
server = null;
- console.log(` replayed: ${recorded.length} requests (left out: ${unanswered} unanswered, ${noRoute} with no route on N-1)`);
// 3. The schema N-1 left, and the SQL it runs on it.
// 3. The schema N-1 left, and the SQL it runs on it.
const db = new Database(dbPath);
const db = new Database(dbPath);
@@ -87,7 +118,8 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -87,7 +118,8 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
fs.mkdirSync(OUT, { recursive: true });
fs.mkdirSync(OUT, { recursive: true });
const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };
const 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' };
- fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, manifest, dynamic_calls: dynamic, calls: recorded, ...(extra ? { extra } : {}) }, null, 1)}\n`);
+ 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`);
fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);
console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
} finally {
} finally {
@@ -96,4 +128,3 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -96,4 +128,3 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
}
}
})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });
})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });
-
@@ -1,13 +1,13 @@
@@ -1,13 +1,13 @@
{
{
"service": "live",
"service": "live",
"release": "8caa540466f9af86247febdbba1339b68919a42f",
"release": "8caa540466f9af86247febdbba1339b68919a42f",
- "recorded_at": "2026-09-26T13:44:31.818Z",
+ "recorded_at": "2026-09-26T14:07:22.139Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"manifest": {
"manifest": {
"service": "live",
"service": "live",
"release": "8caa540466f9",
"release": "8caa540466f9",
"released_at": "2026-09-26T13:22:32.000Z",
"released_at": "2026-09-26T13:22:32.000Z",
- "booted_at": "2026-09-26T13:44:29.263Z",
+ "booted_at": "2026-09-26T14:07:19.453Z",
"contracts_version": "0.56.0",
"contracts_version": "0.56.0",
"packages": {
"packages": {
"openvibe-shared": "1.22.0",
"openvibe-shared": "1.22.0",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"contract_ranges": {},
"contract_ranges": {},
"metrics_url": "/release-metrics"
"metrics_url": "/release-metrics"
},
},
- "dynamic_calls": 49,
+ "dynamic_calls": 44,
"calls": [
"calls": [
{
{
"method": "GET",
"method": "GET",
@@ -1212,6 +1212,34 @@
@@ -1212,6 +1212,34 @@
"public/js/app-channel.js"
"public/js/app-channel.js"
]
]
},
},
+ {
+ "method": "GET",
+ "path": "/api/chat-ai/timeline",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "events": "array",
+ "hasMore": "boolean"
+ },
+ "from": [
+ "public/js/app-chatpage.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/chat-ai/timeline",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "events": "array",
+ "hasMore": "boolean"
+ },
+ "from": [
+ "public/js/app-chatpage.js"
+ ]
+ },
{
{
"method": "GET",
"method": "GET",
"path": "/api/chat-ai/transcript/1",
"path": "/api/chat-ai/transcript/1",
@@ -1371,6 +1399,26 @@
@@ -1371,6 +1399,26 @@
"public/js/stream-player.js"
"public/js/stream-player.js"
]
]
},
},
+ {
+ "method": "GET",
+ "path": "/api/clips/1/job?job=1",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "public/js/ov-clip-jobs.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/clips/1/job?job=1",
+ "auth": "user",
+ "status": 400,
+ "kind": "json",
+ "from": [
+ "public/js/ov-clip-jobs.js"
+ ]
+ },
{
{
"method": "GET",
"method": "GET",
"path": "/api/clips?limit=5&offset=0&auto_generated=0",
"path": "/api/clips?limit=5&offset=0&auto_generated=0",
@@ -1606,48 +1654,48 @@
@@ -1606,48 +1654,48 @@
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/api/controls/presets",
+ "path": "/api/controls/configs/1/bridge-script?type=generic",
"auth": "anon",
"auth": "anon",
- "status": 200,
+ "status": 401,
"kind": "json",
"kind": "json",
- "reads": {
- "controls": "array",
- "settings": "object"
- },
"from": [
"from": [
"public/js/dashboard.js"
"public/js/dashboard.js"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/api/controls/presets",
+ "path": "/api/controls/configs/1/bridge-script?type=generic",
"auth": "user",
"auth": "user",
- "status": 200,
+ "status": 404,
"kind": "json",
"kind": "json",
- "reads": {
- "controls": "array",
- "settings": "object"
- },
"from": [
"from": [
"public/js/dashboard.js"
"public/js/dashboard.js"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/api/controls/presets/1",
+ "path": "/api/controls/presets",
"auth": "anon",
"auth": "anon",
- "status": 404,
+ "status": 200,
"kind": "json",
"kind": "json",
+ "reads": {
+ "controls": "array",
+ "settings": "object"
+ },
"from": [
"from": [
"public/js/dashboard.js"
"public/js/dashboard.js"
]
]
},
},
{
{
"method": "GET",
"method": "GET",
- "path": "/api/controls/presets/1",
+ "path": "/api/controls/presets",
"auth": "user",
"auth": "user",
- "status": 404,
+ "status": 200,
"kind": "json",
"kind": "json",
+ "reads": {
+ "controls": "array",
+ "settings": "object"
+ },
"from": [
"from": [
"public/js/dashboard.js"
"public/js/dashboard.js"
]
]
@@ -6815,6 +6863,705 @@
@@ -6815,6 +6863,705 @@
"public/js/app-media.js"
"public/js/app-media.js"
]
]
},
},
+ {
+ "method": "GET",
+ "path": "/",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/assets/logo.svg",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/manifest.webmanifest",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "name": "string",
+ "description": "string",
+ "scope": "string",
+ "display": "string"
+ },
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/assets/logo-192.png",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/style.css?v=a3843ec6bdd6",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/features/home.css?v=52249fa8d30a",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/home-fx.css?v=8f135af68e76",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/home-tour.css?v=3f1293159aff",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/i18n-star.css?v=3a854948d72f",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/vendor/fontawesome-6.5.1.min.css?v=d6c948f648fd",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/chat-shared.css?v=558d74cf2e6a",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/broadcast.css?v=942804f7c8a1",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/cosmetics.css?v=193dbe9046fb",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/messenger.css?v=8d3312c1f857",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/content",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/moments",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/chat",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/documentation",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/@n1star",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/tos",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/privacy",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/dmca",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/updates",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/content?type=vods",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/content?type=clips",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/content?type=pastes",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/live-notify.js",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/ov-skeleton.js?v=1e6a011f824c",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/ov-loader.js?v=d87608436703",
+ "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": "/js/app.js?v=2b3a4e2cc1c9",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/ov-navbar-live.js?v=54bcbe2d01d1",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/chat-shared.js?v=8fff7cb6e502",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/chat.js?v=a35bbba644ad",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/vibes.js?v=aef505d77f34",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/opencoins.js?v=2e5a908601ff",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/draggable-fab.js?v=fd94fe405afb",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/messenger.js?v=d1d537f2289d",
+ "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": "/js/footer-live.js?v=60c31729accf",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/theme-loader.js?v=d9b9af081efe",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/ov-display-menu.js?v=cb6be0b1998e",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/notification-ui.js?v=a889be53ea64",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/ov-rail.js?v=57c8cd1aca1e",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/ov-live-realtime.js?v=e82b1b7a273a",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/app-home.js?v=d88ec40a35c0",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/ov-odometer.js?v=3f6dee017f2f",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/ov-density.js?v=fc239b8c3986",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/home-fx.js?v=b69411bb61ba",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/home-tour.js?v=a5ded0950973",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/tooltip.js?v=096488cf4948",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/features/channel.css?v=64174155ceda",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/css/context-cards.css?v=60ad33ae3e48",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/ov-clip-jobs.js?v=b674bd0b77b1",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/stream-player.js?v=cd0a5b166216",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/stream-pip.js?v=11ae18077afa",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/controls.js?v=04c5242890ee",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/context-cards.js?v=6719bbc1c432",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/emotes.js?v=0099deffc3c0",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/gif-picker.js?v=5ee269f4e9c1",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/chat-autocomplete.js?v=b726f226680f",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/channel-uploads.js?v=03eb0f258adf",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/app-channel.js?v=747045747200",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /@n1star"
+ ]
+ },
{
{
"method": "PUT",
"method": "PUT",
"path": "/api/admin/users/1",
"path": "/api/admin/users/1",
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"service": "live",
"service": "live",
"release": "8caa540466f9af86247febdbba1339b68919a42f",
"release": "8caa540466f9af86247febdbba1339b68919a42f",
- "recorded_at": "2026-09-26T13:44:31.818Z",
+ "recorded_at": "2026-09-26T14:07:22.139Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"user_version": 0,
"user_version": 0,
"schema": [
"schema": [
@@ -82,6 +82,7 @@
@@ -82,6 +82,7 @@
"CREATE TABLE stream_controls (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n label TEXT NOT NULL,\n command TEXT NOT NULL, -- command string sent to hardware\n icon TEXT DEFAULT 'fa-gamepad', -- Font Awesome icon class\n control_type TEXT DEFAULT 'button' CHECK(control_type IN ('button', 'toggle', 'slider', 'dpad', 'onvif', 'keyboard')),\n key_binding TEXT, -- keyboard shortcut\n cooldown_ms INTEGER DEFAULT 100,\n is_enabled INTEGER DEFAULT 1,\n sort_order INTEGER DEFAULT 0,\n camera_id INTEGER, -- NULL for non-ONVIF controls; references camera_profiles(id)\n onvif_movement TEXT, -- 'pan_left', 'pan_right', 'tilt_up', 'tilt_down', 'zoom_in', 'zoom_out', 'preset'\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, btn_color TEXT DEFAULT '', btn_bg TEXT DEFAULT '', btn_border_color TEXT DEFAULT '',\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,\n FOREIGN KEY (camera_id) REFERENCES camera_profiles(id) ON DELETE SET NULL\n)",
"CREATE TABLE stream_controls (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n label TEXT NOT NULL,\n command TEXT NOT NULL, -- command string sent to hardware\n icon TEXT DEFAULT 'fa-gamepad', -- Font Awesome icon class\n control_type TEXT DEFAULT 'button' CHECK(control_type IN ('button', 'toggle', 'slider', 'dpad', 'onvif', 'keyboard')),\n key_binding TEXT, -- keyboard shortcut\n cooldown_ms INTEGER DEFAULT 100,\n is_enabled INTEGER DEFAULT 1,\n sort_order INTEGER DEFAULT 0,\n camera_id INTEGER, -- NULL for non-ONVIF controls; references camera_profiles(id)\n onvif_movement TEXT, -- 'pan_left', 'pan_right', 'tilt_up', 'tilt_down', 'zoom_in', 'zoom_out', 'preset'\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, btn_color TEXT DEFAULT '', btn_bg TEXT DEFAULT '', btn_border_color TEXT DEFAULT '',\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,\n FOREIGN KEY (camera_id) REFERENCES camera_profiles(id) ON DELETE SET NULL\n)",
"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 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 stream_memories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n offset_seconds INTEGER DEFAULT 0, -- seconds into the stream when captured\n captured_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n description TEXT,\n tags TEXT, -- JSON array\n thumbnail_url TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, transcript_json TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )",
"CREATE TABLE stream_memories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n offset_seconds INTEGER DEFAULT 0, -- seconds into the stream when captured\n captured_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n description TEXT,\n tags TEXT, -- JSON array\n thumbnail_url TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, transcript_json TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )",
+ "CREATE TABLE stream_recaps (\n stream_id INTEGER PRIMARY KEY,\n user_id INTEGER,\n json TEXT NOT NULL,\n ai INTEGER DEFAULT 0,\n announced_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )",
"CREATE TABLE stream_timeline_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n vod_id INTEGER, -- set once the session becomes a VOD\n kind TEXT NOT NULL, -- 'speech' | 'sound'\n start_sec REAL NOT NULL, -- absolute seconds into the stream\n end_sec REAL,\n text TEXT, -- speech content\n label TEXT, -- sound event label\n confidence REAL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, lang TEXT, text_en TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )",
"CREATE TABLE stream_timeline_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n vod_id INTEGER, -- set once the session becomes a VOD\n kind TEXT NOT NULL, -- 'speech' | 'sound'\n start_sec REAL NOT NULL, -- absolute seconds into the stream\n end_sec REAL,\n text TEXT, -- speech content\n label TEXT, -- sound event label\n confidence REAL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, lang TEXT, text_en TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )",
"CREATE TABLE streamer_overviews (\n user_id INTEGER PRIMARY KEY,\n overview TEXT,\n model TEXT,\n sources TEXT,\n generated_at DATETIME DEFAULT CURRENT_TIMESTAMP, overview_short TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE streamer_overviews (\n user_id INTEGER PRIMARY KEY,\n overview TEXT,\n model TEXT,\n sources TEXT,\n generated_at DATETIME DEFAULT CURRENT_TIMESTAMP, overview_short TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE streams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n channel_id INTEGER,\n managed_stream_id INTEGER,\n control_config_id INTEGER,\n title TEXT DEFAULT 'Untitled Stream',\n description TEXT DEFAULT '',\n category TEXT DEFAULT 'irl',\n tags TEXT DEFAULT '[]',\n protocol TEXT DEFAULT 'webrtc' CHECK(protocol IN ('jsmpeg', 'webrtc', 'rtmp')),\n is_live INTEGER DEFAULT 0,\n is_nsfw INTEGER DEFAULT 0,\n viewer_count INTEGER DEFAULT 0,\n peak_viewers INTEGER DEFAULT 0,\n follower_count INTEGER DEFAULT 0,\n thumbnail_url TEXT,\n multi_cam INTEGER DEFAULT 0,\n started_at DATETIME,\n ended_at DATETIME,\n last_heartbeat DATETIME,\n duration_seconds INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ai_overview TEXT, ai_title TEXT, ai_category TEXT, ai_tags TEXT, ai_overview_short TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,\n FOREIGN KEY (managed_stream_id) REFERENCES managed_streams(id) ON DELETE SET NULL,\n FOREIGN KEY (control_config_id) REFERENCES control_configs(id) ON DELETE SET NULL\n)",
"CREATE TABLE streams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n channel_id INTEGER,\n managed_stream_id INTEGER,\n control_config_id INTEGER,\n title TEXT DEFAULT 'Untitled Stream',\n description TEXT DEFAULT '',\n category TEXT DEFAULT 'irl',\n tags TEXT DEFAULT '[]',\n protocol TEXT DEFAULT 'webrtc' CHECK(protocol IN ('jsmpeg', 'webrtc', 'rtmp')),\n is_live INTEGER DEFAULT 0,\n is_nsfw INTEGER DEFAULT 0,\n viewer_count INTEGER DEFAULT 0,\n peak_viewers INTEGER DEFAULT 0,\n follower_count INTEGER DEFAULT 0,\n thumbnail_url TEXT,\n multi_cam INTEGER DEFAULT 0,\n started_at DATETIME,\n ended_at DATETIME,\n last_heartbeat DATETIME,\n duration_seconds INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ai_overview TEXT, ai_title TEXT, ai_category TEXT, ai_tags TEXT, ai_overview_short TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,\n FOREIGN KEY (managed_stream_id) REFERENCES managed_streams(id) ON DELETE SET NULL,\n FOREIGN KEY (control_config_id) REFERENCES control_configs(id) ON DELETE SET NULL\n)",
@@ -212,6 +213,7 @@
@@ -212,6 +213,7 @@
"CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id)",
"CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id)",
"CREATE UNIQUE INDEX idx_stream_memories_moment_unique ON stream_memories(stream_id, offset_seconds)",
"CREATE UNIQUE INDEX idx_stream_memories_moment_unique ON stream_memories(stream_id, offset_seconds)",
"CREATE INDEX idx_stream_memories_stream ON stream_memories(stream_id, offset_seconds)",
"CREATE INDEX idx_stream_memories_stream ON stream_memories(stream_id, offset_seconds)",
+ "CREATE INDEX idx_stream_recaps_user ON stream_recaps(user_id, created_at)",
"CREATE INDEX idx_streams_channel_id ON streams(channel_id)",
"CREATE INDEX idx_streams_channel_id ON streams(channel_id)",
"CREATE INDEX idx_streams_created ON streams(created_at)",
"CREATE INDEX idx_streams_created ON streams(created_at)",
"CREATE INDEX idx_streams_is_live ON streams(is_live)",
"CREATE INDEX idx_streams_is_live ON streams(is_live)",
@@ -255,19 +257,19 @@
@@ -255,19 +257,19 @@
"schema_migrations": [
"schema_migrations": [
{
{
"id": "001_vibes_decimal_to_bits",
"id": "001_vibes_decimal_to_bits",
- "applied_at": "2026-09-26 13:44:27",
+ "applied_at": "2026-09-26 14:07:18",
"mode": "adopted",
"mode": "adopted",
"duration_ms": 0
"duration_ms": 0
},
},
{
{
"id": "002_pastes_ai_columns",
"id": "002_pastes_ai_columns",
- "applied_at": "2026-09-26 13:44:27",
+ "applied_at": "2026-09-26 14:07:18",
"mode": "applied",
"mode": "applied",
"duration_ms": 6
"duration_ms": 6
},
},
{
{
"id": "004_hot_path_indexes",
"id": "004_hot_path_indexes",
- "applied_at": "2026-09-26 13:44:27",
+ "applied_at": "2026-09-26 14:07:18",
"mode": "applied",
"mode": "applied",
"duration_ms": 1
"duration_ms": 1
}
}
@@ -286,7 +288,6 @@
@@ -286,7 +288,6 @@
"CREATE TABLE IF NOT EXISTS search_media_pushes ( kind TEXT NOT NULL, media_id INTEGER NOT NULL, hash TEXT NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (kind, media_id) )",
"CREATE TABLE IF NOT EXISTS search_media_pushes ( kind TEXT NOT NULL, media_id INTEGER NOT NULL, hash TEXT NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (kind, media_id) )",
"CREATE TABLE IF NOT EXISTS billing_actions ( id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, live_user_id INTEGER, live_ref TEXT, method TEXT NOT NULL, path TEXT NOT NULL, request_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', http_status INTEGER, billing_ref TEXT, response_json TEXT, error TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )",
"CREATE TABLE IF NOT EXISTS billing_actions ( id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, live_user_id INTEGER, live_ref TEXT, method TEXT NOT NULL, path TEXT NOT NULL, request_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', http_status INTEGER, billing_ref TEXT, response_json TEXT, error TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )",
"CREATE TABLE IF NOT EXISTS opencoin_admin_grants ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_key TEXT UNIQUE, admin_id INTEGER, user_id INTEGER NOT NULL, amount INTEGER NOT NULL, reason TEXT, balance INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )",
"CREATE TABLE IF NOT EXISTS opencoin_admin_grants ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_key TEXT UNIQUE, admin_id INTEGER, user_id INTEGER NOT NULL, amount INTEGER NOT NULL, reason TEXT, balance INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )",
- "CREATE TABLE IF NOT EXISTS stream_recaps ( stream_id INTEGER PRIMARY KEY, user_id INTEGER, json TEXT NOT NULL, ai INTEGER DEFAULT 0, announced_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )",
"CREATE TABLE IF NOT EXISTS tips_deliveries ( idempotency_key TEXT PRIMARY KEY, effect TEXT, state TEXT NOT NULL DEFAULT 'pending', response_json TEXT, claimed_at INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )",
"CREATE TABLE IF NOT EXISTS tips_deliveries ( idempotency_key TEXT PRIMARY KEY, effect TEXT, state TEXT NOT NULL DEFAULT 'pending', response_json TEXT, claimed_at INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )",
"ALTER TABLE users ADD COLUMN theme_id INTEGER",
"ALTER TABLE users ADD COLUMN theme_id INTEGER",
"ALTER TABLE streams ADD COLUMN call_mode TEXT DEFAULT NULL"
"ALTER TABLE streams ADD COLUMN call_mode TEXT DEFAULT NULL"
@@ -748,6 +749,7 @@
@@ -748,6 +749,7 @@
"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ?",
"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ?",
"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at >= ?",
"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at >= ?",
"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?",
"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?",
+ "SELECT COUNT(*) AS n FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE r.ai = 1 AND COALESCE(u.is_banned, 0) = 0",
"SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) LIKE '%laugh%' OR LOWER(label) LIKE '%giggle%' OR LOWER(label) LIKE '%chuckle%') AND created_at >= datetime('now', ?)",
"SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) LIKE '%laugh%' OR LOWER(label) LIKE '%giggle%' OR LOWER(label) LIKE '%chuckle%') AND created_at >= datetime('now', ?)",
"SELECT COUNT(*) AS n FROM streams WHERE is_live = 1",
"SELECT COUNT(*) AS n FROM streams WHERE is_live = 1",
"SELECT COUNT(*) AS n FROM streams WHERE started_at >= ?",
"SELECT COUNT(*) AS n FROM streams WHERE started_at >= ?",
@@ -921,6 +923,7 @@
@@ -921,6 +923,7 @@
"SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?) AND LENGTH(text) BETWEEN 25 AND 220 ORDER BY created_at DESC LIMIT 1500",
"SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?) AND LENGTH(text) BETWEEN 25 AND 220 ORDER BY created_at DESC LIMIT 1500",
"SELECT id, title, slug FROM managed_streams WHERE user_id = ? AND (? IS NULL OR id != ?) ORDER BY sort_order ASC, id ASC",
"SELECT id, title, slug FROM managed_streams WHERE user_id = ? AND (? IS NULL OR id != ?) ORDER BY sort_order ASC, id ASC",
"SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1",
"SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1",
+ "SELECT id, ts, label, detail FROM chat_timeline_events WHERE scope = ? AND subject_id = ? ORDER BY ts DESC, id DESC LIMIT ?",
"SELECT id, user_id FROM channels WHERE id = ?",
"SELECT id, user_id FROM channels WHERE id = ?",
"SELECT id, user_id, anon_id, username, message, message_type, source_platform, reply_to_id, timestamp FROM chat_messages WHERE channel_user_id = ? AND id > ? AND is_deleted = 0 AND message_type IN ('chat','donation') AND (auto_delete_at IS NULL OR auto_delete_at > CURRENT_TIMESTAMP) ORDER BY id DESC LIMIT ?",
"SELECT id, user_id, anon_id, username, message, message_type, source_platform, reply_to_id, timestamp FROM chat_messages WHERE channel_user_id = ? AND id > ? AND is_deleted = 0 AND message_type IN ('chat','donation') AND (auto_delete_at IS NULL OR auto_delete_at > CURRENT_TIMESTAMP) ORDER BY id DESC LIMIT ?",
"SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams",
"SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams",
@@ -983,6 +986,7 @@
@@ -983,6 +986,7 @@
"SELECT r.rowid AS id, r.platform, r.username, r.display_name, r.message_count, r.last_seen, cs.updated_at AS last_update FROM relay_users r LEFT JOIN chat_ai_summaries cs ON cs.scope = 'relay' AND cs.window = 'rolling' AND cs.subject_id = r.rowid WHERE r.last_seen >= ? AND r.message_count >= ? AND (cs.updated_at IS NULL OR cs.updated_at < r.last_seen) ORDER BY (cs.updated_at IS NULL) DESC, r.last_seen DESC LIMIT ?",
"SELECT r.rowid AS id, r.platform, r.username, r.display_name, r.message_count, r.last_seen, cs.updated_at AS last_update FROM relay_users r LEFT JOIN chat_ai_summaries cs ON cs.scope = 'relay' AND cs.window = 'rolling' AND cs.subject_id = r.rowid WHERE r.last_seen >= ? AND r.message_count >= ? AND (cs.updated_at IS NULL OR cs.updated_at < r.last_seen) ORDER BY (cs.updated_at IS NULL) DESC, r.last_seen DESC LIMIT ?",
"SELECT r.stream_id FROM stream_recaps r JOIN streams s ON s.id = r.stream_id WHERE r.user_id = ? AND s.started_at >= ? ORDER BY r.created_at DESC LIMIT 1",
"SELECT r.stream_id FROM stream_recaps r JOIN streams s ON s.id = r.stream_id WHERE r.user_id = ? AND s.started_at >= ? ORDER BY r.created_at DESC LIMIT 1",
"SELECT r.stream_id, r.json, r.created_at, u.username, u.display_name, u.avatar_url, u.profile_color FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE (? IS NULL OR r.user_id != ?) AND COALESCE(u.is_banned, 0) = 0 ORDER BY r.created_at DESC LIMIT ?",
"SELECT r.stream_id, r.json, r.created_at, u.username, u.display_name, u.avatar_url, u.profile_color FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE (? IS NULL OR r.user_id != ?) AND COALESCE(u.is_banned, 0) = 0 ORDER BY r.created_at DESC LIMIT ?",
+ "SELECT r.stream_id, r.user_id, r.json, r.created_at FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE r.ai = 1 AND COALESCE(u.is_banned, 0) = 0 ORDER BY r.created_at DESC, r.stream_id DESC LIMIT ? OFFSET ?",
"SELECT recorded_at AS t, viewer_count AS v, COALESCE(chat_messages_5m, 0) AS c FROM viewer_snapshots WHERE stream_id = ? ORDER BY recorded_at ASC",
"SELECT recorded_at AS t, viewer_count AS v, COALESCE(chat_messages_5m, 0) AS c FROM viewer_snapshots WHERE stream_id = ? ORDER BY recorded_at ASC",
"SELECT revision FROM subject_projection WHERE subject_id = ?",
"SELECT revision FROM subject_projection WHERE subject_id = ?",
"SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?",
"SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?",
@@ -99,15 +99,21 @@ async function check(name, fn) {
@@ -99,15 +99,21 @@ async function check(name, fn) {
const manifest = await (await fetch(`${server.url}/release.json`)).json();
const manifest = await (await fetch(`${server.url}/release.json`)).json();
console.log('n-1: the N-1 client against this server');
console.log('n-1: the N-1 client against this server');
- await check(`every call the N-1 client makes is answered compatibly (${client.calls.length} requests, in openvibe-shared's mixed-version matrix)`, async () => {
+ 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 broken = [];
const n1Client = async (url) => {
const n1Client = async (url) => {
+ const probe = await h.notFoundProbe(url);
for (const rec of client.calls) {
for (const rec of client.calls) {
- const got = await h.send(url, { method: rec.method, path: rec.path, headers: server.headers(rec.auth), body: rec.method === 'GET' ? undefined : {} });
- const problems = h.callProblems(rec, got);
+ 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('; ')}`);
if (problems.length) broken.push(`${rec.auth} ${rec.method} ${rec.path} (${rec.from.join(', ')}): ${problems.join('; ')}`);
}
}
- if (svc.checkExtra && client.extra) broken.push(...await svc.checkExtra({ server, url, extra: client.extra }));
+ 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)}`);
if (broken.length) throw new Error(`${broken.length} broken:\n${h.summarize(broken)}`);
};
};
await compat.assertMixedVersion({ releases: [
await compat.assertMixedVersion({ releases: [
@@ -94,16 +94,78 @@ function callArgs(src, i) {
@@ -94,16 +94,78 @@ function callArgs(src, i) {
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
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());
+}
+
/**
/**
- * Every call site of the service's request functions with a literal or template path.
+ * 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 }]
* callers: [{ name: 'api', prefix: '/api', method?: 'POST', methodArg?: 1 }]
* strip: template expressions dropped when a path starts with them (e.g. 'API', 'location.origin')
* strip: template expressions dropped when a path starts with them (e.g. 'API', 'location.origin')
- * → [{ method, template, parts, from: [file] }], plus { dynamic } calls with no literal path
+ * 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 }) {
+function extractCalls({ files, callers, strip = [], keep = (p) => true, forms = false }) {
const found = new Map();
const found = new Map();
let dynamic = 0;
let dynamic = 0;
for (const { name: file, text: src } of files) {
for (const { name: file, text: src } of files) {
+ const vars = pathVars(src);
for (const caller of callers) {
for (const caller of callers) {
const re = new RegExp(`(?<![\\w$.])${esc(caller.name)}\\s*\\(`, 'g');
const re = new RegExp(`(?<![\\w$.])${esc(caller.name)}\\s*\\(`, 'g');
let m;
let m;
@@ -111,20 +173,33 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
@@ -111,20 +173,33 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
const open = m.index + m[0].length - 1;
const open = m.index + m[0].length - 1;
const args = callArgs(src, open);
const args = callArgs(src, open);
if (!args || !args.length) continue;
if (!args || !args.length) continue;
- const lit = /^['"`]/.test(args[0]) ? readLiteral(args[0], 0) : null;
- if (!lit || lit.end !== args[0].length) { dynamic++; continue; }
- let parts = lit.parts.slice();
+ // 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();
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].text || !parts[0].text.startsWith('/') || parts[0].text.startsWith('//')) {
if (parts.length && parts[0].expr) dynamic++;
if (parts.length && parts[0].expr) dynamic++;
continue;
continue;
}
}
parts = caller.prefix ? [{ text: caller.prefix }, ...parts] : parts;
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.
// `/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; }
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/');
const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('').replace(/^([^?#]*)\/\//, '$1/');
- let method = caller.method || 'GET';
- if (caller.methodArg != null) {
+ 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 a = args[caller.methodArg];
const ml = a && /^['"`]/.test(a) ? readLiteral(a, 0) : null;
const ml = a && /^['"`]/.test(a) ? readLiteral(a, 0) : null;
if (ml && ml.parts.length === 1 && ml.parts[0].text) method = ml.parts[0].text;
if (ml && ml.parts.length === 1 && ml.parts[0].text) method = ml.parts[0].text;
@@ -134,7 +209,29 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
@@ -134,7 +209,29 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
if (mm) method = mm[1];
if (mm) method = mm[1];
}
}
method = method.toUpperCase();
method = method.toUpperCase();
- if (!keep(template.split('?')[0], method)) continue;
+ 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}`;
const key = `${method} ${template}`;
if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });
if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });
found.get(key).from.add(file);
found.get(key).from.add(file);
@@ -146,6 +243,54 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
@@ -146,6 +243,54 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
return { calls, dynamic };
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
* 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.
* expression text, else '1' in the path and '' in the query.
@@ -161,12 +306,55 @@ function concretePath(parts, samples = []) {
@@ -161,12 +306,55 @@ function concretePath(parts, samples = []) {
const hit = samples.find(([re]) => re.test(p.expr));
const hit = samples.find(([re]) => re.test(p.expr));
let v = hit ? hit[1] : (inQuery ? '' : '1');
let v = hit ? hit[1] : (inQuery ? '' : '1');
if (typeof v === 'function') v = v(p.expr, inQuery);
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 += inQuery ? String(v) : encodeURIComponent(String(v)).replace(/%2F/gi, '/');
}
}
out = out.replace(/^([^?#]*)\/\/+/, '$1/').replace(/[?&]+$/, '').replace(/\?&+/, '?');
out = out.replace(/^([^?#]*)\/\/+/, '$1/').replace(/[?&]+$/, '').replace(/\?&+/, '?');
return out;
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. */
/** Property names the client code reads: `.name`, `?.name`, `['name']`, 'name' literals, destructured names. */
function clientIdentifiers(texts) {
function clientIdentifiers(texts) {
const ids = new Set();
const ids = new Set();
@@ -304,8 +492,10 @@ async function send(baseUrl, { method, path: p, headers = {}, body }, { timeoutM
@@ -304,8 +492,10 @@ async function send(baseUrl, { method, path: p, headers = {}, body }, { timeoutM
const kind = kindOf(ct, text);
const kind = kindOf(ct, text);
let json = null;
let json = null;
if (kind === 'json') { try { json = JSON.parse(text); } catch { 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');
const loc = res.headers.get('location');
- return { status: res.status, kind, json, location: loc ? loc.replace(/^https?:\/\/[^/]+/, '') : null };
+ // 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) {
} catch (err) {
return { status: 0, kind: 'error', json: null, error: err.name === 'AbortError' ? 'timeout' : err.message };
return { status: 0, kind: 'error', json: null, error: err.name === 'AbortError' ? 'timeout' : err.message };
} finally {
} finally {
@@ -333,10 +523,33 @@ function requestsFor(calls, samples, { signedIn = true } = {}) {
@@ -333,10 +523,33 @@ function requestsFor(calls, samples, { signedIn = true } = {}) {
return reqs.sort((a, b) => rank(a) - rank(b));
return reqs.sort((a, b) => rank(a) - rank(b));
}
}
-/** The failures of one replayed call against its recorded outcome. */
-function callProblems(rec, got) {
+/**
+ * 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 = [];
const problems = [];
if (got.status === 0) return [`no answer (${got.error})`];
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}`);
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`);
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.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}`);
@@ -344,6 +557,117 @@ function callProblems(rec, got) {
@@ -344,6 +557,117 @@ function callProblems(rec, got) {
return problems;
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 ──────────────────────────────────────────────────────
// ── SQL ──────────────────────────────────────────────────────
const SQL_START = /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i;
const SQL_START = /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i;
@@ -506,6 +830,13 @@ function worktree(root, ref) {
@@ -506,6 +830,13 @@ function worktree(root, ref) {
};
};
}
}
+/** 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 ────────────────────────────────────────────────
// ── Reporting ────────────────────────────────────────────────
function summarize(problems, max = 40) {
function summarize(problems, max = 40) {
@@ -515,9 +846,10 @@ function summarize(problems, max = 40) {
@@ -515,9 +846,10 @@ function summarize(problems, max = 40) {
}
}
module.exports = {
module.exports = {
- readLiteral, callArgs, extractCalls, concretePath, clientIdentifiers,
+ readLiteral, callArgs, splitTop, pathParts, extractCalls, concretePath, fillPath, unfillPath, pageLinks, clientIdentifiers,
flatten, readsOf, readProblems, statusCompatible,
flatten, readsOf, readProblems, statusCompatible,
- send, requestsFor, callProblems,
+ send, requestsFor, notFoundProbe, isNoRoute, callProblems,
+ wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
- worktree, summarize,
+ worktree, gitFiles, summarize,
};
};
@@ -69,6 +69,9 @@ module.exports = {
@@ -69,6 +69,9 @@ module.exports = {
],
],
strip: ['API', 'location.origin', 'window.location.origin'],
strip: ['API', 'location.origin', 'window.location.origin'],
keep: (pathname) => !CHAT_PATHS.test(pathname),
keep: (pathname) => !CHAT_PATHS.test(pathname),
+ /** Pages an open tab may hold: the shell (its scripts, styles and links) and a server-rendered channel. */
+ crawl: ['/', '/@n1star'],
+ origins: ['https://openvibe.live'],
/** Values for template expressions, first match wins: the seeded streamer, ids of seeded rows. */
/** Values for template expressions, first match wins: the seeded streamer, ids of seeded rows. */
samples: [
samples: [
[/id\)*$/i, '1'],
[/id\)*$/i, '1'],