openvibers/openvibe.community Commits
N-1 tests (roadmap WS-P task 11): the previous release's clients against this server, and its SQL against this schema. test/n-1.test.js boots this checkout as production runs it (pastes authority community, the shared database) with its Live, Network and Media mocks, on a database created from the previous release's schema, so Community's migrations run over it, and replays test/fixtures/n-1/client.json inside openvibe-shared's mixed-version matrix (release-compat assertMixedVersion): 78 requests (community.js, the templates' forms, the openvibe-sdk community client, and every link, script and form of ten pages that release served: /, /pastes, a paste, a space, a thread, a comment thread, /search, /pulse, /new, /my; every call anonymously, reads signed in with the ov_token cookie), each answered compatibly (any 2xx for a 2xx, the same status otherwise, JSON where there was JSON, redirects to the same place, every response field the client reads with a type it had). Then every statement in test/fixtures/n-1/worker.json (175: what that release ran, plus its SQL literals) must still prepare on the migrated schema, and no old INSERT may miss a new NOT NULL column without a default. Fixtures recorded from 19441aa, the release in production; refresh after each deploy with `npm run n-1:record [ref]` (a temporary worktree of the release, booted, seeded, replayed). The harness is the file Live, Chat and Media share (test/n-1/harness.js); test/n-1/service.js and boot-child.js are Community's part. In npm test, so in CI, about 2 s, no git history or network. Checked by breaking it: a paste's views as a string fails it.
Changes
@@@ -4,7 +4,9 @@ on:branches: [main]pull_request:jobs:-- # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).++ # install, node --check, npm test (with test/n-1.test.js: the previous release's pages, forms and SDK++ # calls, and its SQL, against this one, from test/fixtures/n-1) and openvibe-contracts-check: the shared++ # test job (Track Q).test:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:@@@ -571,8 +571,17 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0npm installnpm start # http://127.0.0.1:4200npm test # mocks Live, Network and Media in-process; no network needed++npm run n-1:record # after a deploy: the N-1 fixtures from the deployed commit (or pass <ref>)```++`test/n-1.test.js` (roadmap WS-P task 11, in `npm test`) runs the previous release's clients against++this one and its SQL against this schema, from `test/fixtures/n-1/`: every call `community.js`, the++templates' forms and the openvibe-sdk community client make, and every link, script and form of the++pages that release served, each answered compatibly (status, JSON, the response fields the client++reads); then every statement the previous release runs must still prepare after this release opened++(and migrated) a database the previous one created. After each deploy, record the release now in++production as the next N-1 and commit `test/fixtures/n-1/`.++## Deploy```@@@ -7,7 +7,8 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/run.js"++ "test": "node test/run.js",++ "n-1:record": "node scripts/n-1-record.js"},"keywords": ["openvibe",nnew file mode 100644@@@ -0,0 +1,130 @@++#!/usr/bin/env node++'use strict';++/**++ * Records the N-1 fixtures (roadmap WS-P task 11) from a release: what its client calls and reads,++ * and what SQL it runs on which schema. test/n-1.test.js replays them against this checkout.++ *++ * npm run n-1:record # from HEAD: run it right after a deploy, from the deployed commit,++ * # so the fixture is N-1 for the next release++ * npm run n-1:record -- <ref> # from another commit (e.g. the sha in production's /release.json)++ *++ * The release is checked out into a temporary git worktree (sharing node_modules), seeded and booted++ * the way test/n-1/service.js says, and every call its client code makes (test/n-1/harness.js finds++ * them) is replayed against it; a service with sockets also replays the messages its clients send.++ * Writes test/fixtures/n-1/client.json and worker.json; commit them.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const Database = require('better-sqlite3');++const h = require('../test/n-1/harness');++const svc = require('../test/n-1/service');++++const ROOT = path.join(__dirname, '..');++const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');++++(async () => {++ const ref = process.argv[2] || 'HEAD';++ const wt = h.worktree(ROOT, ref);++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-record-'));++ const dbPath = path.join(tmp, 'db', `${svc.service}.db`);++ const dataDir = path.join(tmp, 'data');++ const sqlOut = path.join(tmp, 'sql.json');++ fs.mkdirSync(path.dirname(dbPath), { recursive: true });++ fs.mkdirSync(dataDir, { recursive: true });++ let server = null;++ try {++ console.log(`n-1:record: ${svc.service} at ${wt.sha.slice(0, 12)} (${ref})`);++++ // 1. The client: its calls, and the names it reads.++ const files = svc.clientFiles(wt.dir);++ const { calls, dynamic } = h.extractCalls({ files, callers: svc.callers, strip: svc.strip, keep: svc.keep, forms: !!svc.forms });++ const ids = h.clientIdentifiers(files.map((f) => f.text));++ console.log(` client: ${files.length} files, ${calls.length} call sites with a path (${dynamic} with a computed one, not replayed)`);++++ // 2. The release, seeded and booted; every call replayed.++ await svc.seed({ dir: wt.dir, dbPath, dataDir });++ server = await svc.boot({ dir: wt.dir, dbPath, dataDir, sqlOut });++ const manifest = await (await fetch(`${server.url}/release.json`)).json();++ const probe = await h.notFoundProbe(server.url);++ const requests = h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false });++ // Server-rendered pages: an open tab holds the page N-1 served, and follows what it links to.++ if (svc.crawl) {++ const have = new Set(requests.map((r) => `${r.method} ${r.path} ${r.auth}`));++ const push = (r) => { const k = `${r.method} ${r.path} ${r.auth}`; if (!have.has(k)) { have.add(k); requests.push(r); } };++ for (const page of svc.crawl) {++ push({ method: 'GET', path: page, auth: 'anon', accept: 'html', from: ['page'] });++ const got = await h.send(server.url, { method: 'GET', path: h.fillPath(page, server.ids), headers: { accept: 'text/html' } });++ for (const l of got.html ? h.pageLinks(got.html, svc.origins) : []) {++ const p = h.unfillPath(l.path, server.ids);++ if (!svc.keep || svc.keep(p.split('?')[0], l.method, `page ${page}`)) push({ method: l.method, path: p, auth: 'anon', accept: 'html', from: [`page ${page}`] });++ }++ }++ requests.sort((a, b) => (a.method === 'GET' ? 0 : 1) - (b.method === 'GET' ? 0 : 1));++ }++ const recorded = [];++ let unanswered = 0; let noRoute = 0;++ for (const req of requests) {++ const got = await h.send(server.url, { method: req.method, path: h.fillPath(req.path, server.ids), headers: { ...(req.accept ? { accept: 'text/html' } : {}), ...server.headers(req.auth) }, body: req.method === 'GET' ? undefined : {} });++ if (got.status === 0) { unanswered++; continue; }++ // No route for this path on N-1 itself (a sample value its route does not take, or a dead call):++ // nothing N has to keep.++ if (h.isNoRoute(got, probe)) { noRoute++; continue; }++ const rec = { method: req.method, path: req.path, auth: req.auth, ...(req.accept ? { accept: req.accept } : {}), status: got.status, kind: got.kind };++ if (got.location) rec.location = got.location;++ if (got.json != null && got.status >= 200 && got.status < 300) {++ const reads = h.readsOf(got.json, ids);++ if (Object.keys(reads).length) rec.reads = reads;++ }++ rec.from = req.from;++ recorded.push(rec);++ }++ console.log(` replayed: ${recorded.length} requests (left out: ${unanswered} unanswered, ${noRoute} with no route on N-1)`);++++ // Sockets: the messages the client sends, in one session per sign-in state; what comes back.++ let ws = null;++ if (svc.ws) {++ const wsFiles = svc.ws.files ? files.filter((f) => svc.ws.files(f.name)) : files;++ const sends = h.wsSends(wsFiles, svc.ws.helpers);++ const handled = h.wsHandled(wsFiles);++ ws = { path: svc.ws.path, sends, sessions: [] };++ for (const auth of ['anon', 'user']) {++ const messages = h.wsMessages(sends, { order: svc.ws.order, values: server.wsValues(auth) });++ const received = await h.wsSession({ url: server.url.replace(/^http/, 'ws') + svc.ws.path, headers: svc.ws.headers, messages });++ ws.sessions.push({ auth, received: h.wsReads(received.filter((m) => !(svc.ws.ignore || []).includes(m.type)), handled, ids) });++ }++ console.log(` sockets: ${sends.length} message types sent; received, of the types the client handles: ${ws.sessions.map((x) => `${x.auth} ${Object.keys(x.received).length}`).join(', ')}`);++ }++ await server.close();++ server = null;++++ // 3. The schema N-1 left, and the SQL it runs on it.++ const db = new Database(dbPath);++ const schema = h.schemaDDL(db);++ const ledger = {};++ for (const t of svc.ledgerTables || []) {++ try { ledger[t] = db.prepare(`SELECT * FROM "${t}"`).all(); } catch { /* not there */ }++ }++ const userVersion = db.pragma('user_version', { simple: true });++ const serverFiles = h.readTree(wt.dir, svc.sqlDirs);++ // What N-1 creates itself on first use or at a full boot: in production it exists before N deploys.++ const lazy = h.lazyDDL(serverFiles, db);++ const ran = fs.existsSync(sqlOut) ? JSON.parse(fs.readFileSync(sqlOut, 'utf8')) : [];++ const candidates = [...new Set([...ran.map(h.normalizeSql).filter((s) => /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i.test(s)), ...h.sqlLiterals(serverFiles)])].sort();++ const failing = new Set(h.prepareProblems(db, candidates).map((p) => p.sql));++ const statements = candidates.filter((s) => !failing.has(s));++ db.close();++ console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${schema.length} schema objects, ${lazy.length} created on first use`);++++ fs.mkdirSync(OUT, { recursive: true });++ const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };++ const clients = svc.clientReleases ? svc.clientReleases() : null;++ fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);++ fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);++ console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);++ } finally {++ if (server) await server.close();++ wt.remove();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,1090 @@++{++ "service": "community",++ "release": "19441aa2511a7648f1dd3c1d9f70fe74e41cb530",++ "recorded_at": "2026-09-26T14:07:23.825Z",++ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",++ "manifest": {++ "service": "community",++ "release": "19441aa2511a",++ "released_at": "2026-09-26T13:13:46.000Z",++ "booted_at": "2026-09-26T14:07:22.838Z",++ "contracts_version": "0.49.0",++ "packages": {++ "openvibe-shared": "1.22.0",++ "openvibe-sdk": "0.11.0"++ },++ "min_client_release": null,++ "mixed_version_window_hours": 24,++ "components": {++ "shell": {++ "kind": "script",++ "version": "19441aa2511a"++ },++ "server": {++ "kind": "server",++ "version": "19441aa2511a"++ }++ },++ "assets": {},++ "schema_generation": null,++ "schema_compatible_from": null,++ "contract_ranges": {},++ "metrics_url": "/release-metrics"++ },++ "dynamic_calls": 3,++ "calls": [++ {++ "method": "GET",++ "path": "/api/pastes",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "pastes": "array",++ "pastes[]": "object",++ "pastes[].id": "number",++ "pastes[].slug": "string",++ "pastes[].user_id": "string",++ "pastes[].owner_subject": "string",++ "pastes[].origin": "string",++ "pastes[].type": "string",++ "pastes[].title": "string",++ "pastes[].content": "string",++ "pastes[].language": "string",++ "pastes[].visibility": "string",++ "pastes[].screenshot_url": "null",++ "pastes[].burn_after_read": "boolean",++ "pastes[].pinned": "boolean",++ "pastes[].views": "number",++ "pastes[].is_nsfw": "boolean",++ "pastes[].ai_summary": "null",++ "pastes[].revision": "number",++ "pastes[].url": "string",++ "pastes[].created_at": "string",++ "pastes[].updated_at": "string",++ "pastes[].is_owner": "boolean",++ "pastes[].username": "string",++ "pastes[].display_name": "string",++ "pastes[].avatar_url": "null",++ "total": "number",++ "limit": "number",++ "offset": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "pastes": "array",++ "pastes[]": "object",++ "pastes[].id": "number",++ "pastes[].slug": "string",++ "pastes[].user_id": "string",++ "pastes[].owner_subject": "string",++ "pastes[].origin": "string",++ "pastes[].type": "string",++ "pastes[].title": "string",++ "pastes[].content": "string",++ "pastes[].language": "string",++ "pastes[].visibility": "string",++ "pastes[].screenshot_url": "null",++ "pastes[].burn_after_read": "boolean",++ "pastes[].pinned": "boolean",++ "pastes[].views": "number",++ "pastes[].is_nsfw": "boolean",++ "pastes[].ai_summary": "null",++ "pastes[].revision": "number",++ "pastes[].url": "string",++ "pastes[].created_at": "string",++ "pastes[].updated_at": "string",++ "pastes[].is_owner": "boolean",++ "pastes[].username": "string",++ "pastes[].display_name": "string",++ "pastes[].avatar_url": "null",++ "total": "number",++ "limit": "number",++ "offset": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/by-user/n1star",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "pastes": "array",++ "pastes[]": "object",++ "pastes[].id": "number",++ "pastes[].slug": "string",++ "pastes[].user_id": "string",++ "pastes[].owner_subject": "string",++ "pastes[].origin": "string",++ "pastes[].type": "string",++ "pastes[].title": "string",++ "pastes[].content": "string",++ "pastes[].language": "string",++ "pastes[].visibility": "string",++ "pastes[].screenshot_url": "null",++ "pastes[].burn_after_read": "boolean",++ "pastes[].pinned": "boolean",++ "pastes[].views": "number",++ "pastes[].is_nsfw": "boolean",++ "pastes[].ai_summary": "null",++ "pastes[].revision": "number",++ "pastes[].url": "string",++ "pastes[].created_at": "string",++ "pastes[].updated_at": "string",++ "pastes[].is_owner": "boolean",++ "pastes[].username": "string",++ "pastes[].display_name": "string",++ "pastes[].avatar_url": "null",++ "total": "number",++ "username": "string"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/by-user/n1star",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "pastes": "array",++ "pastes[]": "object",++ "pastes[].id": "number",++ "pastes[].slug": "string",++ "pastes[].user_id": "string",++ "pastes[].owner_subject": "string",++ "pastes[].origin": "string",++ "pastes[].type": "string",++ "pastes[].title": "string",++ "pastes[].content": "string",++ "pastes[].language": "string",++ "pastes[].visibility": "string",++ "pastes[].screenshot_url": "null",++ "pastes[].burn_after_read": "boolean",++ "pastes[].pinned": "boolean",++ "pastes[].views": "number",++ "pastes[].is_nsfw": "boolean",++ "pastes[].ai_summary": "null",++ "pastes[].revision": "number",++ "pastes[].url": "string",++ "pastes[].created_at": "string",++ "pastes[].updated_at": "string",++ "pastes[].is_owner": "boolean",++ "pastes[].username": "string",++ "pastes[].display_name": "string",++ "pastes[].avatar_url": "null",++ "total": "number",++ "username": "string"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/config",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/config",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/{@paste}",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "paste": "object",++ "paste.id": "number",++ "paste.slug": "string",++ "paste.user_id": "string",++ "paste.owner_subject": "string",++ "paste.origin": "string",++ "paste.type": "string",++ "paste.title": "string",++ "paste.content": "string",++ "paste.language": "string",++ "paste.visibility": "string",++ "paste.screenshot_url": "null",++ "paste.burn_after_read": "boolean",++ "paste.pinned": "boolean",++ "paste.views": "number",++ "paste.is_nsfw": "boolean",++ "paste.ai_summary": "null",++ "paste.revision": "number",++ "paste.url": "string",++ "paste.created_at": "string",++ "paste.updated_at": "string",++ "paste.is_owner": "boolean",++ "paste.username": "string",++ "paste.display_name": "string",++ "paste.avatar_url": "null"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/{@paste}",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "paste": "object",++ "paste.id": "number",++ "paste.slug": "string",++ "paste.user_id": "string",++ "paste.owner_subject": "string",++ "paste.origin": "string",++ "paste.type": "string",++ "paste.title": "string",++ "paste.content": "string",++ "paste.language": "string",++ "paste.visibility": "string",++ "paste.screenshot_url": "null",++ "paste.burn_after_read": "boolean",++ "paste.pinned": "boolean",++ "paste.views": "number",++ "paste.is_nsfw": "boolean",++ "paste.ai_summary": "null",++ "paste.revision": "number",++ "paste.url": "string",++ "paste.created_at": "string",++ "paste.updated_at": "string",++ "paste.is_owner": "boolean",++ "paste.username": "string",++ "paste.display_name": "string",++ "paste.avatar_url": "null"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/{@paste}/comments",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "reads": {++ "comments": "array",++ "total": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/{@paste}/comments",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "comments": "array",++ "total": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/{@paste}/versions",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/pastes/{@paste}/versions",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "revision": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "GET",++ "path": "/pastes",++ "auth": "anon",++ "status": 200,++ "kind": "html",++ "from": [++ "server/render/pages.js"++ ]++ },++ {++ "method": "GET",++ "path": "/pastes",++ "auth": "user",++ "status": 200,++ "kind": "html",++ "from": [++ "server/render/pages.js"++ ]++ },++ {++ "method": "GET",++ "path": "/search",++ "auth": "anon",++ "status": 200,++ "kind": "html",++ "from": [++ "server/render/search.js"++ ]++ },++ {++ "method": "GET",++ "path": "/search",++ "auth": "user",++ "status": 200,++ "kind": "html",++ "from": [++ "server/render/search.js"++ ]++ },++ {++ "method": "GET",++ "path": "/",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page"++ ]++ },++ {++ "method": "GET",++ "path": "/assets/logo-192.png",++ "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",++ "display": "string"++ },++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/feed.xml",++ "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": "/css/community.css?v=3bf26cdbb2",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/navbar.js?v=b50d9f47fc34",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/footer.js?v=b99e512398d5",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/js/community.js?v=d3f570525d",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/s",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/new",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/auth/login?next=%2F",++ "auth": "anon",++ "accept": "html",++ "status": 302,++ "kind": "html",++ "location": "/oauth/authorize",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/p/{@paste}",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/pastes?lang=javascript",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/pastes?sort=views",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/s/general",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/pulse",++ "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": "/sitemap.xml",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/p/{@paste}/raw",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /p/{@paste}"++ ]++ },++ {++ "method": "GET",++ "path": "/p/{@paste}/download",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /p/{@paste}"++ ]++ },++ {++ "method": "GET",++ "path": "/new?fork={@paste}",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /p/{@paste}"++ ]++ },++ {++ "method": "GET",++ "path": "/s/discuss?paste={@paste}",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /p/{@paste}"++ ]++ },++ {++ "method": "GET",++ "path": "/s/general/feed.xml",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /s/general"++ ]++ },++ {++ "method": "GET",++ "path": "/s/general/new",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /s/general"++ ]++ },++ {++ "method": "GET",++ "path": "/s/general/t/{@thread_slug}",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /s/general"++ ]++ },++ {++ "method": "GET",++ "path": "/s/general/t/n-1-thread",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page"++ ]++ },++ {++ "method": "GET",++ "path": "/auth/login?next=%2Fs%2Fgeneral%2Ft%2F{@thread_slug}",++ "auth": "anon",++ "accept": "html",++ "status": 302,++ "kind": "html",++ "location": "/oauth/authorize",++ "from": [++ "page /s/general/t/n-1-thread"++ ]++ },++ {++ "method": "GET",++ "path": "/c/{@comment_access}",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page"++ ]++ },++ {++ "method": "GET",++ "path": "/c/{@comment_thread}",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /c/{@comment_access}"++ ]++ },++ {++ "method": "GET",++ "path": "/auth/login?next=%2Fc%2F{@comment_thread}",++ "auth": "anon",++ "accept": "html",++ "status": 302,++ "kind": "html",++ "location": "/oauth/authorize",++ "from": [++ "page /c/{@comment_access}"++ ]++ },++ {++ "method": "GET",++ "path": "/s/feed.xml",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /pulse"++ ]++ },++ {++ "method": "GET",++ "path": "/pulse?origin=user",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /pulse"++ ]++ },++ {++ "method": "GET",++ "path": "/pulse?origin=ai",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /pulse"++ ]++ },++ {++ "method": "GET",++ "path": "/pulse?origin=system",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /pulse"++ ]++ },++ {++ "method": "GET",++ "path": "/auth/login?next=%2Fnew",++ "auth": "anon",++ "accept": "html",++ "status": 302,++ "kind": "html",++ "location": "/oauth/authorize",++ "from": [++ "page /new"++ ]++ },++ {++ "method": "GET",++ "path": "/my",++ "auth": "anon",++ "accept": "html",++ "status": 302,++ "kind": "html",++ "location": "/auth/login",++ "from": [++ "page"++ ]++ },++ {++ "method": "POST",++ "path": "/api/pastes",++ "auth": "anon",++ "status": 400,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js",++ "public/js/community.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/pastes/screenshot",++ "auth": "anon",++ "status": 400,++ "kind": "json",++ "from": [++ "public/js/community.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/pastes/{@paste}",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "PUT",++ "path": "/api/pastes/{@paste}",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/pastes/{@paste}/comments",++ "auth": "anon",++ "status": 400,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/pastes/{@paste}/comments/1",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/pastes/{@paste}/copy",++ "auth": "anon",++ "status": 200,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/pastes/{@paste}/fork",++ "auth": "anon",++ "status": 201,++ "kind": "json",++ "reads": {++ "id": "number",++ "slug": "string",++ "url": "string",++ "paste": "object",++ "paste.id": "number",++ "paste.slug": "string",++ "paste.user_id": "null",++ "paste.owner_subject": "null",++ "paste.origin": "string",++ "paste.type": "string",++ "paste.title": "string",++ "paste.content": "string",++ "paste.language": "string",++ "paste.visibility": "string",++ "paste.screenshot_url": "null",++ "paste.burn_after_read": "boolean",++ "paste.pinned": "boolean",++ "paste.views": "number",++ "paste.is_nsfw": "boolean",++ "paste.ai_summary": "null",++ "paste.revision": "number",++ "paste.url": "string",++ "paste.created_at": "string",++ "paste.updated_at": "string",++ "paste.is_owner": "boolean",++ "paste.username": "null",++ "paste.display_name": "null",++ "paste.avatar_url": "null"++ },++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/pastes/{@paste}/like",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/community.js"++ ]++ },++ {++ "method": "POST",++ "path": "/c/{@comment_access}",++ "auth": "anon",++ "status": 303,++ "kind": "empty",++ "location": "/auth/login",++ "from": [++ "server/render/comments.js"++ ]++ },++ {++ "method": "POST",++ "path": "/new",++ "auth": "anon",++ "status": 400,++ "kind": "html",++ "from": [++ "server/render/pages.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/new-space",++ "auth": "anon",++ "status": 403,++ "kind": "html",++ "from": [++ "server/render/board.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/chat-room",++ "auth": "anon",++ "status": 303,++ "kind": "empty",++ "location": "/auth/login",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/chat-room/detach",++ "auth": "anon",++ "status": 303,++ "kind": "empty",++ "location": "/auth/login",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/new",++ "auth": "anon",++ "status": 303,++ "kind": "empty",++ "location": "/auth/login",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/settings",++ "auth": "anon",++ "status": 403,++ "kind": "html",++ "from": [++ "server/render/board.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/t/n-1-thread/category",++ "auth": "anon",++ "status": 403,++ "kind": "html",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/t/n-1-thread/crosspost",++ "auth": "anon",++ "status": 303,++ "kind": "empty",++ "location": "/auth/login",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/t/n-1-thread/delete",++ "auth": "anon",++ "status": 403,++ "kind": "html",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/t/n-1-thread/members-only",++ "auth": "anon",++ "status": 403,++ "kind": "html",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/t/n-1-thread/react",++ "auth": "anon",++ "status": 303,++ "kind": "empty",++ "location": "/auth/login",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/t/n-1-thread/reply",++ "auth": "anon",++ "status": 303,++ "kind": "empty",++ "location": "/auth/login",++ "from": [++ "server/render/board.js",++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/t/n-1-thread/state",++ "auth": "anon",++ "status": 403,++ "kind": "html",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/t/n-1-thread/status",++ "auth": "anon",++ "status": 403,++ "kind": "html",++ "from": [++ "server/render/forum.js"++ ]++ },++ {++ "method": "POST",++ "path": "/s/general/t/n-1-thread/vote",++ "auth": "anon",++ "status": 400,++ "kind": "html",++ "from": [++ "server/render/forum.js"++ ]++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,245 @@++{++ "service": "community",++ "release": "19441aa2511a7648f1dd3c1d9f70fe74e41cb530",++ "recorded_at": "2026-09-26T14:07:23.825Z",++ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",++ "user_version": 0,++ "schema": [++ "CREATE TABLE attachments (\n media_id TEXT PRIMARY KEY,\n owner_subject TEXT NOT NULL,\n post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,\n position INTEGER NOT NULL DEFAULT 0,\n filename TEXT,\n mime TEXT NOT NULL,\n size_bytes INTEGER NOT NULL,\n url TEXT NOT NULL,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE categories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,\n slug TEXT NOT NULL,\n name TEXT NOT NULL,\n description TEXT,\n position INTEGER NOT NULL DEFAULT 0,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n UNIQUE (space_id, slug)\n)",++ "CREATE TABLE comment_threads (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ref_service TEXT NOT NULL,\n ref_type TEXT NOT NULL,\n ref_id TEXT NOT NULL,\n ref_label TEXT, -- cached display label from a service; never authoritative\n visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')),\n comment_count INTEGER NOT NULL DEFAULT 0,\n created_by TEXT, -- subject or service principal that first resolved it\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n access_id TEXT, -- unguessable handle for browsers (migrate() fills old rows, unique index)\n UNIQUE (ref_service, ref_type, ref_id)\n)",++ "CREATE TABLE comment_votes (\n comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n value INTEGER NOT NULL CHECK(value IN (-1, 1)),\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (comment_id, subject_id)\n)",++ "CREATE TABLE comments (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n thread_id INTEGER NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,\n parent_id INTEGER REFERENCES comments(id) ON DELETE CASCADE,\n author_subject TEXT,\n anon_name TEXT,\n origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai')),\n message TEXT NOT NULL,\n score INTEGER NOT NULL DEFAULT 0,\n upvotes INTEGER NOT NULL DEFAULT 0,\n downvotes INTEGER NOT NULL DEFAULT 0,\n reply_count INTEGER NOT NULL DEFAULT 0,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME,\n deleted_by TEXT,\n edited_at DATETIME -- the author last changed the text (migrate() adds it to older databases)\n)",++ "CREATE TABLE community_event_inbox (\n consumer TEXT NOT NULL,\n event_id TEXT NOT NULL,\n processed_at INTEGER NOT NULL,\n PRIMARY KEY (consumer, event_id)\n )",++ "CREATE TABLE import_hold (\n source_type TEXT NOT NULL,\n source_id TEXT NOT NULL,\n reason TEXT NOT NULL,\n detail TEXT,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (source_type, source_id, reason)\n)",++ "CREATE TABLE legacy_id_map (\n source_system TEXT NOT NULL,\n source_type TEXT NOT NULL,\n source_id TEXT NOT NULL,\n target_type TEXT NOT NULL,\n target_id TEXT NOT NULL,\n PRIMARY KEY (source_system, source_type, source_id)\n)",++ "CREATE TABLE migration_runs (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source TEXT NOT NULL,\n started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n finished_at DATETIME,\n report TEXT\n)",++ "CREATE TABLE paste_comments (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n author_subject TEXT,\n anon_name TEXT,\n parent_id INTEGER REFERENCES paste_comments(id) ON DELETE CASCADE,\n message TEXT NOT NULL,\n is_deleted INTEGER NOT NULL DEFAULT 0,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n legacy_media_id INTEGER UNIQUE\n)",++ "CREATE TABLE paste_likes (\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (paste_id, subject_id)\n)",++ "CREATE TABLE paste_versions (\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n revision INTEGER NOT NULL,\n title TEXT,\n content TEXT,\n language TEXT,\n edited_by TEXT,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (paste_id, revision)\n)",++ "CREATE TABLE paste_visits (\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n visitor TEXT NOT NULL,\n first_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n last_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n visits INTEGER NOT NULL DEFAULT 1,\n PRIMARY KEY (paste_id, visitor)\n)",++ "CREATE TABLE pastes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT UNIQUE NOT NULL,\n owner_subject TEXT,\n origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'imported')),\n type TEXT NOT NULL DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),\n title TEXT NOT NULL DEFAULT 'Untitled',\n content TEXT,\n language TEXT DEFAULT 'text',\n visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),\n screenshot_url TEXT,\n media_ref TEXT,\n stream_ref TEXT, -- JSON EntityRef, e.g. {\"service\":\"live\",\"type\":\"stream\",\"id\":\"123\"}\n metadata TEXT, -- JSON\n burn_after_read INTEGER NOT NULL DEFAULT 0,\n forked_from INTEGER REFERENCES pastes(id) ON DELETE SET NULL,\n pinned INTEGER NOT NULL DEFAULT 0,\n views INTEGER NOT NULL DEFAULT 0,\n unique_views INTEGER NOT NULL DEFAULT 0,\n copies INTEGER NOT NULL DEFAULT 0,\n likes INTEGER NOT NULL DEFAULT 0,\n is_nsfw INTEGER NOT NULL DEFAULT 0,\n ai_summary TEXT,\n ai_tags TEXT, -- JSON array\n ai_analyzed_at DATETIME,\n revision INTEGER NOT NULL DEFAULT 1,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME,\n legacy_media_id INTEGER UNIQUE,\n legacy_user_id INTEGER\n)",++ "CREATE TABLE post_pastes (\n post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n position INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (post_id, paste_id)\n)",++ "CREATE TABLE post_reactions (\n post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n reaction TEXT NOT NULL,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (post_id, subject_id)\n)",++ "CREATE TABLE post_versions (\n post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,\n revision INTEGER NOT NULL,\n body_markdown TEXT NOT NULL,\n edited_by TEXT,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (post_id, revision)\n)",++ "CREATE TABLE posts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,\n author_subject TEXT,\n origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),\n is_opening INTEGER NOT NULL DEFAULT 0,\n body_markdown TEXT NOT NULL,\n revision INTEGER NOT NULL DEFAULT 1,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME\n, relay_author TEXT)",++ "CREATE TABLE pulse_items (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source_service TEXT NOT NULL,\n source_type TEXT NOT NULL,\n source_id TEXT NOT NULL,\n title TEXT NOT NULL,\n url TEXT NOT NULL,\n actor_subject TEXT,\n origin TEXT NOT NULL CHECK(origin IN ('user', 'ai', 'system')),\n visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public')),\n occurred_at DATETIME NOT NULL,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n UNIQUE (source_service, source_type, source_id)\n)",++ "CREATE TABLE relay_cursors (\n name TEXT PRIMARY KEY,\n cursor INTEGER NOT NULL,\n latest_seq INTEGER,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE relay_deliveries (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,\n post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,\n mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,\n action TEXT NOT NULL DEFAULT 'create' CHECK(action IN ('create', 'edit', 'delete')),\n dedupe_key TEXT UNIQUE NOT NULL,\n source TEXT NOT NULL DEFAULT 'direct' CHECK(source IN ('direct', 'events')),\n event_id TEXT,\n status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed', 'dropped', 'skipped')),\n attempts INTEGER NOT NULL DEFAULT 0,\n next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n last_status INTEGER,\n last_error TEXT,\n delivered_at DATETIME,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE relay_inbound_failures (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n mapping_id INTEGER REFERENCES relay_mappings(id) ON DELETE SET NULL,\n event TEXT NOT NULL,\n external_channel_id TEXT,\n external_message_id TEXT,\n external_author_id TEXT,\n error TEXT NOT NULL,\n dismissed_at DATETIME,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE relay_mappings (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,\n direction TEXT NOT NULL DEFAULT 'out' CHECK(direction IN ('out')),\n webhook_url_ref TEXT NOT NULL,\n enabled INTEGER NOT NULL DEFAULT 1,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, discord_channel_id TEXT, discord_thread_id TEXT, inbound INTEGER NOT NULL DEFAULT 0,\n UNIQUE (space_id, direction, webhook_url_ref)\n)",++ "CREATE TABLE relay_message_map (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n platform TEXT NOT NULL DEFAULT 'discord' CHECK(platform IN ('discord')),\n mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,\n direction TEXT NOT NULL CHECK(direction IN ('out', 'in')),\n local_type TEXT NOT NULL CHECK(local_type IN ('thread', 'post')),\n local_id INTEGER NOT NULL,\n thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,\n external_channel_id TEXT NOT NULL,\n external_thread_id TEXT,\n external_message_id TEXT NOT NULL,\n external_webhook_id TEXT,\n external_deleted_at DATETIME,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n UNIQUE (platform, external_message_id),\n UNIQUE (platform, mapping_id, local_type, local_id)\n)",++ "CREATE TABLE space_chat_rooms (\n space_id INTEGER PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,\n room_id INTEGER,\n room_slug TEXT NOT NULL,\n room_name TEXT NOT NULL,\n room_kind TEXT NOT NULL DEFAULT 'community',\n room_visibility TEXT NOT NULL DEFAULT 'public',\n attached_by TEXT,\n attached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE space_groups (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT UNIQUE NOT NULL,\n name TEXT NOT NULL,\n description TEXT,\n position INTEGER NOT NULL DEFAULT 0\n)",++ "CREATE TABLE spaces (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT UNIQUE NOT NULL,\n name TEXT NOT NULL,\n description TEXT,\n visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'members', 'staff')),\n created_by TEXT,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n, members_only_owner TEXT, thread_kind TEXT NOT NULL DEFAULT 'discussion', style TEXT NOT NULL DEFAULT 'feed', votes INTEGER NOT NULL DEFAULT 1, reactions INTEGER NOT NULL DEFAULT 1, group_id INTEGER REFERENCES space_groups(id) ON DELETE SET NULL, parent_id INTEGER REFERENCES spaces(id) ON DELETE SET NULL, position INTEGER NOT NULL DEFAULT 0)",++ "CREATE TABLE subject_projection (\n subject_id TEXT PRIMARY KEY,\n username TEXT,\n display_name TEXT,\n avatar_url TEXT,\n profile_color TEXT,\n refreshed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE thread_votes (\n thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n value INTEGER NOT NULL CHECK(value IN (-1, 1)),\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (thread_id, subject_id)\n)",++ "CREATE TABLE threads (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,\n slug TEXT NOT NULL,\n title TEXT NOT NULL,\n author_subject TEXT,\n origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),\n pinned INTEGER NOT NULL DEFAULT 0,\n locked INTEGER NOT NULL DEFAULT 0,\n score INTEGER NOT NULL DEFAULT 0,\n reply_count INTEGER NOT NULL DEFAULT 0,\n last_activity_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME, members_only_owner TEXT, kind TEXT NOT NULL DEFAULT 'discussion', status TEXT, category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, external_key TEXT, views INTEGER NOT NULL DEFAULT 0, crosspost_of INTEGER REFERENCES threads(id) ON DELETE SET NULL,\n UNIQUE (space_id, slug)\n)",++ "CREATE TABLE token_revocations (\n subject_id TEXT PRIMARY KEY,\n valid_after_ms INTEGER NOT NULL,\n reason TEXT,\n updated_at INTEGER NOT NULL\n )",++ "CREATE INDEX idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL",++ "CREATE UNIQUE INDEX idx_comment_threads_access ON comment_threads(access_id)",++ "CREATE INDEX idx_comments_parent ON comments(parent_id, id)",++ "CREATE INDEX idx_comments_thread ON comments(thread_id, parent_id, id)",++ "CREATE INDEX idx_paste_comments_paste ON paste_comments(paste_id, parent_id)",++ "CREATE INDEX idx_paste_visits_last ON paste_visits(last_at)",++ "CREATE INDEX idx_pastes_forked ON pastes(forked_from)",++ "CREATE INDEX idx_pastes_listing ON pastes(visibility, created_at DESC) WHERE deleted_at IS NULL",++ "CREATE INDEX idx_pastes_owner ON pastes(owner_subject, created_at DESC)",++ "CREATE INDEX idx_post_reactions_post ON post_reactions(post_id, reaction)",++ "CREATE INDEX idx_posts_author ON posts(author_subject, created_at DESC)",++ "CREATE INDEX idx_posts_thread ON posts(thread_id, id)",++ "CREATE INDEX idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC)",++ "CREATE INDEX idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC)",++ "CREATE INDEX idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at)",++ "CREATE INDEX idx_relay_deliveries_thread ON relay_deliveries(thread_id, post_id)",++ "CREATE INDEX idx_relay_mappings_channel ON relay_mappings(discord_channel_id) WHERE discord_channel_id IS NOT NULL",++ "CREATE INDEX idx_relay_message_map_thread ON relay_message_map(thread_id)",++ "CREATE INDEX idx_subject_projection_username ON subject_projection(username COLLATE NOCASE)",++ "CREATE INDEX idx_threads_author ON threads(author_subject, created_at DESC)",++ "CREATE INDEX idx_threads_category ON threads(category_id) WHERE category_id IS NOT NULL",++ "CREATE UNIQUE INDEX idx_threads_external_key ON threads(space_id, external_key) WHERE external_key IS NOT NULL",++ "CREATE INDEX idx_threads_space_new ON threads(space_id, created_at DESC) WHERE deleted_at IS NULL"++ ],++ "ledger": {},++ "lazy": [++ "CREATE TABLE IF NOT EXISTS network_blocks ( blocker_subject TEXT NOT NULL, blocked_subject TEXT NOT NULL, active INTEGER NOT NULL, revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (blocker_subject, blocked_subject) )",++ "CREATE TABLE IF NOT EXISTS profile_module_pushes ( subject_id TEXT PRIMARY KEY, hash TEXT NOT NULL, pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP )"++ ],++ "statements": [++ "DELETE FROM categories WHERE id = ?",++ "DELETE FROM import_hold WHERE source_type = ? AND source_id = ?",++ "DELETE FROM import_hold WHERE source_type = ? AND source_id = ? AND reason IN ('ambiguous_owner', 'owner_unmapped')",++ "DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?",++ "DELETE FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?",++ "DELETE FROM relay_inbound_failures WHERE id <= (SELECT MAX(id) FROM relay_inbound_failures) - ?",++ "DELETE FROM space_chat_rooms WHERE space_id = ?",++ "INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url) VALUES (?, ?, ?, ?, ?, ?)",++ "INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?) ON CONFLICT (space_id, slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position",++ "INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by, access_id) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING",++ "INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?)",++ "INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message, created_at, updated_at, edited_at) VALUES (?, ?, ?, NULL, 'user', ?, ?, ?, ?)",++ "INSERT INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?) ON CONFLICT(source_type, source_id, reason) DO UPDATE SET detail = excluded.detail",++ "INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, 'comment', ?) ON CONFLICT(source_system, source_type, source_id) DO NOTHING",++ "INSERT INTO migration_runs (source, finished_at, report) VALUES (?, CURRENT_TIMESTAMP, ?)",++ "INSERT INTO migration_runs (source, started_at, finished_at, report) VALUES (?, ?, ?, ?)",++ "INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at WHERE excluded.revision > network_blocks.revision",++ "INSERT INTO paste_comments (paste_id, author_subject, anon_name, message, is_deleted, created_at, updated_at, legacy_media_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO paste_visits (paste_id, visitor) VALUES (?, ?)",++ "INSERT INTO pastes (slug, owner_subject, origin, type, title, content, language, visibility, forked_from) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO pastes (slug, owner_subject, origin, type, title, content, language, visibility, stream_ref, metadata, burn_after_read, is_nsfw) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?) ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = CURRENT_TIMESTAMP",++ "INSERT INTO post_versions (post_id, revision, body_markdown, edited_by) VALUES (?, ?, ?, ?)",++ "INSERT INTO post_versions (post_id, revision, body_markdown, edited_by, created_at) VALUES (?, ?, ?, ?, ?)",++ "INSERT INTO posts (thread_id, author_subject, origin, body_markdown, relay_author) VALUES (?, ?, ?, ?, ?)",++ "INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)",++ "INSERT INTO profile_module_pushes (subject_id, hash, pushed_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(subject_id) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at",++ "INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?)",++ "INSERT INTO relay_cursors (name, cursor, latest_seq, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor, latest_seq = excluded.latest_seq, updated_at = CURRENT_TIMESTAMP",++ "INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, action, dedupe_key, source, event_id, next_attempt_at) VALUES (@thread_id, @post_id, @mapping_id, @action, @key, @source, @event_id, @at) ON CONFLICT(dedupe_key) DO NOTHING",++ "INSERT INTO relay_inbound_failures (mapping_id, event, external_channel_id, external_message_id, external_author_id, error) VALUES (?, ?, ?, ?, ?, ?)",++ "INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id) VALUES ('discord', ?, 'in', 'post', ?, ?, ?, ?)",++ "INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (space_id) DO UPDATE SET room_id = excluded.room_id, room_slug = excluded.room_slug, room_name = excluded.room_name, room_kind = excluded.room_kind, room_visibility = excluded.room_visibility, attached_by = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_by ELSE excluded.attached_by END, attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE CURRENT_TIMESTAMP END",++ "INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?) ON CONFLICT (slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position",++ "INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at) VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, CURRENT_TIMESTAMP) ON CONFLICT(subject_id) DO UPDATE SET username = COALESCE(excluded.username, username), display_name = COALESCE(excluded.display_name, display_name), avatar_url = COALESCE(excluded.avatar_url, avatar_url), profile_color = COALESCE(excluded.profile_color, profile_color), refreshed_at = CURRENT_TIMESTAMP",++ "INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO token_revocations (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at WHERE excluded.valid_after_ms > token_revocations.valid_after_ms",++ "INSERT OR IGNORE INTO categories (space_id, slug, name, description, position) SELECT id, ?, ?, ?, ? FROM spaces WHERE slug = ?",++ "INSERT OR IGNORE INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)",++ "INSERT OR IGNORE INTO paste_likes (paste_id, subject_id, created_at) VALUES (?, ?, ?)",++ "INSERT OR IGNORE INTO post_pastes (post_id, paste_id, position) VALUES (?, ?, ?)",++ "SELECT * FROM attachments WHERE media_id = ?",++ "SELECT * FROM attachments WHERE post_id IN (?) ORDER BY post_id, position",++ "SELECT * FROM categories WHERE id = ?",++ "SELECT * FROM categories WHERE space_id = ? AND slug = ?",++ "SELECT * FROM comment_threads WHERE access_id = ?",++ "SELECT * FROM comment_threads WHERE id = ?",++ "SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?",++ "SELECT * FROM comments WHERE id = ?",++ "SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?",++ "SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND (deleted_at IS NULL OR reply_count > 0) ORDER BY id DESC LIMIT ?",++ "SELECT * FROM paste_comments WHERE legacy_media_id = ?",++ "SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC",++ "SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",++ "SELECT * FROM pastes",++ "SELECT * FROM pastes WHERE (updated_at >= @from AND updated_at < @to) OR (deleted_at >= @from AND deleted_at < @to) OR (created_at >= @from AND created_at < @to)",++ "SELECT * FROM pastes WHERE deleted_at IS NULL AND owner_subject = ? ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ? OFFSET ?",++ "SELECT * FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 AND owner_subject = ? ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ? OFFSET ?",++ "SELECT * FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 AND type = ? ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ? OFFSET ?",++ "SELECT * FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ? OFFSET ?",++ "SELECT * FROM pastes WHERE id = ? AND deleted_at IS NULL",++ "SELECT * FROM pastes WHERE legacy_media_id = ?",++ "SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL",++ "SELECT * FROM posts WHERE id = ?",++ "SELECT * FROM posts WHERE thread_id = ? ORDER BY id ASC LIMIT ? OFFSET ?",++ "SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?",++ "SELECT * FROM relay_mappings WHERE id = ?",++ "SELECT * FROM relay_message_map WHERE platform = 'discord' AND external_message_id = ?",++ "SELECT * FROM space_chat_rooms WHERE space_id = ?",++ "SELECT * FROM space_groups ORDER BY position, name COLLATE NOCASE",++ "SELECT * FROM spaces WHERE id = ?",++ "SELECT * FROM spaces WHERE slug = ?",++ "SELECT * FROM subject_projection WHERE subject_id = ?",++ "SELECT * FROM threads WHERE id = ?",++ "SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL",++ "SELECT * FROM threads WHERE space_id = ? AND external_key = ?",++ "SELECT * FROM threads WHERE space_id = ? AND slug = ? AND deleted_at IS NULL",++ "SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1",++ "SELECT 1 FROM paste_likes WHERE paste_id = ? AND subject_id = ?",++ "SELECT 1 FROM pastes WHERE slug = ?",++ "SELECT 1 FROM post_versions WHERE post_id = ? AND revision = ?",++ "SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id WHERE p.id = ? AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'",++ "SELECT 1 FROM threads WHERE space_id = ? AND slug = ?",++ "SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0",++ "SELECT COUNT(*) AS c FROM pastes WHERE owner_subject = ? AND created_at > datetime('now', ?)",++ "SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)",++ "SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?",++ "SELECT COUNT(*) AS c FROM threads WHERE author_subject = ? AND created_at > datetime('now', ?)",++ "SELECT COUNT(*) AS n FROM relay_inbound_failures WHERE dismissed_at IS NULL",++ "SELECT COUNT(*) AS n FROM relay_message_map WHERE external_deleted_at IS NULL",++ "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'",++ "SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM comments WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'",++ "SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM pastes WHERE owner_subject = ? AND deleted_at IS NULL AND origin = 'user' AND visibility = 'public'",++ "SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM posts WHERE author_subject = ? AND deleted_at IS NULL AND is_opening = 0 AND origin = 'user'",++ "SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM threads WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'",++ "SELECT COUNT(*) AS total FROM pastes WHERE deleted_at IS NULL AND owner_subject = ?",++ "SELECT COUNT(*) AS total FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0",++ "SELECT COUNT(*) AS total FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 AND owner_subject = ?",++ "SELECT COUNT(*) AS total FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 AND type = ?",++ "SELECT COUNT(*) AS total FROM posts WHERE thread_id = ?",++ "SELECT COUNT(*) AS total FROM threads WHERE space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status)",++ "SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM posts WHERE author_subject IN (?) AND deleted_at IS NULL GROUP BY author_subject",++ "SELECT author_subject FROM posts WHERE id = ?",++ "SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1",++ "SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count FROM categories c WHERE c.space_id = ? ORDER BY c.position, c.name COLLATE NOCASE",++ "SELECT copies FROM pastes WHERE id = ?",++ "SELECT cursor, latest_seq FROM relay_cursors WHERE name = ?",++ "SELECT f.*, s.slug AS space_slug FROM relay_inbound_failures f LEFT JOIN relay_mappings m ON m.id = f.mapping_id LEFT JOIN spaces s ON s.id = m.space_id WHERE (? = 1 OR f.dismissed_at IS NULL) ORDER BY f.id DESC LIMIT ?",++ "SELECT hash FROM profile_module_pushes WHERE subject_id = ?",++ "SELECT i.* FROM pulse_items i WHERE (@origin IS NULL OR i.origin = @origin) AND (@at IS NULL OR i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id)) AND (i.source_service <> 'community' OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public')) OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.visibility = 'public' AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL)) OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public' AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))) ORDER BY i.occurred_at DESC, i.id DESC LIMIT @limit",++ "SELECT id FROM comment_threads WHERE access_id IS NULL",++ "SELECT id FROM posts WHERE thread_id = ?",++ "SELECT id FROM space_groups WHERE slug = ?",++ "SELECT id FROM threads",++ "SELECT id FROM threads WHERE space_id = ?",++ "SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1",++ "SELECT id, legacy_media_id FROM pastes WHERE slug = ?",++ "SELECT id, slug, visibility, burn_after_read, deleted_at FROM pastes WHERE slug = ?",++ "SELECT last_at FROM paste_visits WHERE paste_id = ? AND visitor = ?",++ "SELECT name FROM sqlite_master WHERE type = 'table'",++ "SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id WHERE p.author_subject IN (?) AND p.deleted_at IS NULL GROUP BY p.author_subject, r.reaction",++ "SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (?) ORDER BY created_at, subject_id",++ "SELECT pp.post_id, p.slug, p.title, p.language, p.type, p.screenshot_url, p.visibility, p.content FROM post_pastes pp JOIN pastes p ON p.id = pp.paste_id WHERE pp.post_id IN (?) AND p.deleted_at IS NULL AND p.visibility != 'private' AND p.burn_after_read = 0 ORDER BY pp.post_id, pp.position",++ "SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?",++ "SELECT revision, body_markdown, edited_by, created_at FROM post_versions WHERE post_id = ? ORDER BY revision ASC",++ "SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC",++ "SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position, ps.slug AS parent_slug, ps.name AS parent_name, (SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count, (SELECT COUNT(*) FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = s.id AND t.deleted_at IS NULL AND p.deleted_at IS NULL) AS post_count, (SELECT MAX(t.last_activity_at) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS last_activity_at FROM spaces s LEFT JOIN space_groups g ON g.id = s.group_id LEFT JOIN spaces ps ON ps.id = s.parent_id WHERE s.visibility IN (?) ORDER BY g.position IS NULL, g.position, s.position, s.id ASC",++ "SELECT slug, name FROM space_groups WHERE id = ?",++ "SELECT slug, name FROM spaces WHERE id = ?",++ "SELECT slug, name, description, position FROM space_groups WHERE slug = ?",++ "SELECT slug, visibility FROM spaces WHERE id = ?",++ "SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status",++ "SELECT subject_id FROM subject_projection WHERE username = ? COLLATE NOCASE ORDER BY refreshed_at DESC",++ "SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.deleted_at IS NULL AND s.visibility IN (?) AND (? IS NULL OR s.slug = ?) AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL ORDER BY t.created_at DESC, t.id DESC LIMIT ?",++ "SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_id FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULL ORDER BY p.id DESC LIMIT 1",++ "SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id",++ "SELECT target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?",++ "SELECT threads.*, (SELECT p.author_subject FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_author_subject, (SELECT p.origin FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_origin, (SELECT p.id FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_post_id FROM threads WHERE space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status) ORDER BY pinned DESC, last_activity_at DESC, id DESC LIMIT @limit OFFSET @offset",++ "SELECT title FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility != 'private' AND burn_after_read = 0",++ "SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?",++ "SELECT views, unique_views FROM pastes WHERE id = ?",++ "UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL",++ "UPDATE comment_threads SET access_id = ? WHERE id = ?",++ "UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE comment_threads SET ref_label = ?, updated_at = CURRENT_TIMESTAMP WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, '') <> ?",++ "UPDATE comment_threads SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP, deleted_by = ?, message = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE comments SET message = ?, edited_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL",++ "UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?",++ "UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?",++ "UPDATE paste_comments SET parent_id = ? WHERE id = ? AND parent_id IS NOT ?",++ "UPDATE pastes SET copies = copies + 1 WHERE id = ?",++ "UPDATE pastes SET forked_from = ? WHERE id = ? AND forked_from IS NOT ?",++ "UPDATE pastes SET views = views + 1, unique_views = unique_views + 1 WHERE id = ?",++ "UPDATE posts SET body_markdown = ?, revision = revision + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE posts SET deleted_at = CURRENT_TIMESTAMP, body_markdown = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE pulse_items SET title = ?, url = ? WHERE source_service = ? AND source_type = ? AND source_id = ?",++ "UPDATE relay_deliveries SET status = 'dropped', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IN ('pending', 'failed')",++ "UPDATE relay_deliveries SET status = 'pending', attempts = 0, next_attempt_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status <> 'delivered'",++ "UPDATE relay_inbound_failures SET dismissed_at = CURRENT_TIMESTAMP WHERE id = ? AND dismissed_at IS NULL",++ "UPDATE relay_message_map SET external_deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND external_deleted_at IS NULL",++ "UPDATE relay_message_map SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE spaces SET group_id = (SELECT id FROM space_groups WHERE slug = ?), position = ?, style = ?, votes = ? WHERE slug = ? AND created_by = 'system' AND group_id IS NULL",++ "UPDATE spaces SET members_only_owner = ? WHERE id = ?",++ "UPDATE threads SET category_id = ? WHERE id = ?",++ "UPDATE threads SET category_id = NULL WHERE category_id = ?",++ "UPDATE threads SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL",++ "UPDATE threads SET members_only_owner = ? WHERE id = ?",++ "UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?",++ "UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE threads SET status = ? WHERE id = ?",++ "UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?",++ "UPDATE threads SET views = views + 1 WHERE id = ?"++ ]++}nnew file mode 100644@@@ -0,0 +1,142 @@++'use strict';++/**++ * N-1 (roadmap WS-P task 11, ADR-016): during a deploy, and for the 24-hour mixed-version window after++ * it, open tabs run the previous release's client against this release's server, and a previous-release++ * process can still be working on the database this release migrated. Both must keep working.++ *++ * - An N-1 client against the N server: test/fixtures/n-1/client.json is every call the previous++ * release's client code makes (found statically, made concrete with seeded values) with what that++ * release answered: status, JSON or not, and the response fields the old client reads with their++ * types. This checkout boots (test/n-1/service.js) and must answer each call compatibly: any 2xx++ * for a 2xx, the same status otherwise (a 5xx only needs the route), JSON where there was JSON,++ * every read field still there with a type it had. The run sits inside openvibe-shared's++ * mixed-version matrix (release-compat assertMixedVersion) with both releases' manifests.++ * - N-1 SQL against the N schema: test/fixtures/n-1/worker.json holds the previous release's schema++ * (and migration ledger, and what it creates on first use) and every statement it ran or has as a++ * literal. The database this test boots on is created from that schema, so this release's++ * migrations run over it; afterwards every N-1 statement must still prepare (no table or column it++ * names is gone), and no N-1 INSERT may miss a column this release made NOT NULL without a default.++ *++ * The fixtures come from the release in production: `npm run n-1:record` after each deploy (see++ * scripts/n-1-record.js). This test needs no git history and no network.++ *++ * node test/n-1.test.js++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const Database = require('better-sqlite3');++const compat = require('openvibe-shared/release-compat');++const h = require('./n-1/harness');++const svc = require('./n-1/service');++++const ROOT = path.join(__dirname, '..');++const FIX = path.join(__dirname, 'fixtures', 'n-1');++const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));++const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${String(err.message).split('\n').join('\n ')}`); }++}++++(async () => {++ const age = Math.round((Date.now() - Date.parse(client.recorded_at)) / 86400000);++ console.log(`n-1: ${svc.service} N-1 = ${client.release.slice(0, 12)}, recorded ${client.recorded_at.slice(0, 10)} (${age} d ago); ${client.calls.length} calls, ${worker.statements.length} statements`);++++ console.log('n-1: the harness');++ await check('finds call sites, their method and template, and skips computed paths', () => {++ const files = [{ name: 'a.js', text: "api('/streams'); api(`/users/${encodeURIComponent(u.username)}/follow`, { method: 'POST', body: {} }); fetch(`${API}/api/x?limit=${n}`); api(path); fetch('https://elsewhere/x'); api(`/b/${on ? 'mute' : 'unmute'}`, { method: 'post' });" }];++ const { calls, dynamic } = h.extractCalls({ files, callers: [{ name: 'api', prefix: '/api' }, { name: 'fetch' }], strip: ['API'] });++ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.template}`), ['POST /api/b/{on ? \'mute\' : \'unmute\'}', 'GET /api/streams', 'POST /api/users/{encodeURIComponent(u.username)}/follow', 'GET /api/x?limit={n}']);++ assert.strictEqual(dynamic, 1);++ const samples = [[/id\)*$/i, '1'], [/name\)*$/i, 'n1star']];++ assert.deepStrictEqual(calls.map((c) => h.concretePath(c.parts, samples)), ['/api/b/mute', '/api/streams', '/api/users/n1star/follow', '/api/x?limit=']);++ });++ await check('reads: the key paths the client names; a gone or retyped one is a problem, an empty list or null parent is not', () => {++ const ids = h.clientIdentifiers(['x.streams.map((s) => s.title); const { total } = d; s?.viewer_count']);++ const reads = h.readsOf({ streams: [{ title: 'a', viewer_count: 1, secret: 'k' }], total: 3, other: 1 }, ids);++ assert.deepStrictEqual(reads, { streams: 'array', 'streams[]': 'object', 'streams[].title': 'string', 'streams[].viewer_count': 'number', total: 'number' });++ assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: 2 }], total: 1 }), []);++ assert.deepStrictEqual(h.readProblems(reads, { streams: [], total: 0 }), []);++ assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: '2' }] }), ['streams[].viewer_count was number, now string', 'total is gone']);++ assert.ok(h.statusCompatible(200, 201) && h.statusCompatible(502, 503) && !h.statusCompatible(401, 403) && !h.statusCompatible(502, 404) && !h.statusCompatible(200, 404));++ });++ await check('SQL: a dropped column fails the prepare; a new NOT NULL column without a default fails an old INSERT', () => {++ const d = new Database(':memory:');++ d.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT NOT NULL, c TEXT NOT NULL DEFAULT \'x\')');++ assert.deepStrictEqual(h.prepareProblems(d, ['SELECT a, gone FROM t']).map((p) => p.error), ['no such column: gone']);++ assert.deepStrictEqual(h.insertProblems(d, ['INSERT INTO t (a, b) VALUES (?, ?)', 'INSERT INTO t (a) VALUES (?)']).map((p) => p.error), ['t.b is NOT NULL without a default, and N-1 does not set it']);++ d.close();++ });++++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-test-'));++ const dbPath = path.join(tmp, 'db', `${svc.service}.db`);++ const dataDir = path.join(tmp, 'data');++ fs.mkdirSync(path.dirname(dbPath), { recursive: true });++ fs.mkdirSync(dataDir, { recursive: true });++ let server = null;++ try {++ console.log(`n-1: this release on a database N-1 created`);++ await check('N-1\'s schema, ledger and first-use tables load, and this release migrates and seeds it', () => {++ const d = new Database(dbPath);++ d.transaction(() => {++ for (const ddl of worker.schema) d.exec(ddl);++ for (const [table, rows] of Object.entries(worker.ledger || {})) {++ for (const row of rows) {++ const cols = Object.keys(row);++ d.prepare(`INSERT INTO "${table}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...cols.map((c) => row[c]));++ }++ }++ for (const ddl of worker.lazy || []) d.exec(ddl);++ })();++ d.pragma(`user_version = ${Number(worker.user_version) || 0}`);++ d.close();++ svc.seed({ dir: ROOT, dbPath, dataDir });++ });++ server = await svc.boot({ dir: ROOT, dbPath, dataDir });++ const manifest = await (await fetch(`${server.url}/release.json`)).json();++++ console.log('n-1: the N-1 client against this server');++ const sockets = client.ws ? `, and ${client.ws.sends.length} socket message types in ${client.ws.sessions.length} sessions` : '';++ await check(`every call the N-1 client makes is answered compatibly (${client.calls.length} requests${sockets}, in openvibe-shared's mixed-version matrix)`, async () => {++ const broken = [];++ const n1Client = async (url) => {++ const probe = await h.notFoundProbe(url);++ for (const rec of client.calls) {++ const got = await h.send(url, { method: rec.method, path: h.fillPath(rec.path, server.ids), headers: { ...(rec.accept ? { accept: 'text/html' } : {}), ...server.headers(rec.auth) }, body: rec.method === 'GET' ? undefined : {} });++ const problems = h.callProblems(rec, got, probe);++ if (problems.length) broken.push(`${rec.auth} ${rec.method} ${rec.path} (${rec.from.join(', ')}): ${problems.join('; ')}`);++ }++ for (const session of (client.ws && client.ws.sessions) || []) {++ const messages = h.wsMessages(client.ws.sends, { order: svc.ws.order, values: server.wsValues(session.auth) });++ const received = await h.wsSession({ url: url.replace(/^http/, 'ws') + client.ws.path, headers: svc.ws.headers, messages });++ broken.push(...h.wsProblems(session.received, received).map((p) => `socket ${client.ws.path} (${session.auth}): ${p}`));++ }++ if (broken.length) throw new Error(`${broken.length} broken:\n${h.summarize(broken)}`);++ };++ await compat.assertMixedVersion({ releases: [++ { name: 'N-1', manifest: client.manifest, client: n1Client },++ { name: 'N', manifest, server: async () => ({ url: server.url }) },++ ] });++ });++ await server.close();++ server = null;++++ console.log('n-1: N-1\'s SQL on the schema this release migrated');++ await check(`every statement N-1 runs still prepares (${worker.statements.length}), and none of its INSERTs misses a new required column`, () => {++ const d = new Database(dbPath, { readonly: true });++ const problems = [...h.prepareProblems(d, worker.statements), ...h.insertProblems(d, worker.statements)];++ d.close();++ assert.deepStrictEqual(problems.map((p) => `${p.error}: ${p.sql.slice(0, 160)}`), [], 'N-1 SQL that breaks on this schema (expand first, contract a release later: ADR-028)');++ });++ } finally {++ if (server) { console.log(server.log().slice(-1500)); await server.close(); }++ fs.rmSync(tmp, { recursive: true, force: true });++ }++++ if (failures) { console.log(`\nn-1: ${failures} check(s) failed`); process.exit(1); }++ console.log('\nn-1: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++/**++ * Runs inside a Community release (its directory is the cwd): starts that release's own Live, Network++ * and Media mocks (test/helpers/mock-*.js), then the app as production runs it (pastes authority++ * community, the shared database at COMMUNITY_DB_PATH = N1_DB), seeds a thread, a paste and a comment++ * through the API, and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it.++ */++const http = require('http');++const path = require('path');++++(async () => {++ const dir = process.cwd();++ console.warn = () => {};++ const mockLive = require(path.join(dir, 'test', 'helpers', 'mock-live'));++ const mockNetwork = require(path.join(dir, 'test', 'helpers', 'mock-network'));++ const mockMedia = require(path.join(dir, 'test', 'helpers', 'mock-media'));++ const liveSrv = await mockLive.start();++ const net = await mockNetwork.start();++ const mediaSrv = await mockMedia.start({ publicPem: net.publicPem, issuer: net.url });++ Object.assign(process.env, {++ NODE_ENV: 'test',++ BASE_URL: 'https://openvibe.community',++ OV_LIVE_INTERNAL_URL: liveSrv.url,++ OV_LIVE_URL: 'https://openvibe.live',++ OV_MEDIA_URL: 'https://openvibe.media',++ OV_NETWORK_URL: net.url,++ OV_NETWORK_INTERNAL_URL: net.url,++ OV_OAUTH_CLIENT_ID: 'community',++ OV_OAUTH_CLIENT_SECRET: 'shh',++ OV_OAUTH_REDIRECT_URI: 'https://openvibe.community/auth/callback',++ COOKIE_SECURE: 'true',++ TRUST_PROXY: '1',++ OV_MEDIA_INTERNAL_URL: mediaSrv.url,++ PASTES_AUTHORITY: 'community',++ COMMUNITY_DB_PATH: process.env.N1_DB,++ });++ const { createApp } = require(path.join(dir, 'server', 'app'));++ const app = createApp({++ pasteLimits: { cooldownSeconds: 0 },++ forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 },++ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },++ });++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const url = `http://127.0.0.1:${server.address().port}`;++++ const star = net.addUser({ network_user_id: 7, username: 'n1star', display_name: 'N1 Star' });++ const token = net.sign({ id: 7, subject_id: star.subject_id, username: 'n1star', display_name: 'N1 Star', role: 'user' });++ const call = async (p, { method = 'GET', json } = {}) => {++ const res = await fetch(url + p, { method, headers: { cookie: `ov_token=${token}`, ...(json ? { 'content-type': 'application/json' } : {}) }, body: json ? JSON.stringify(json) : undefined });++ let body = null; try { body = await res.json(); } catch { /* */ }++ if (!res.ok) process.stderr.write(`[n-1] seed ${method} ${p}: ${res.status} ${JSON.stringify(body).slice(0, 200)}\n`);++ return body || {};++ };++ const thread = (await call('/api/v1/spaces/general/threads', { method: 'POST', json: { title: 'N-1 thread', body: 'Seeded for the N-1 test' } })).thread;++ if (thread) await call(`/api/v1/threads/${thread.id}/posts`, { method: 'POST', json: { body: 'A reply from N-1' } });++ const paste = (await call('/api/pastes', { method: 'POST', json: { title: 'N-1 paste', content: 'console.log("n-1")', language: 'javascript' } })).paste;++ const ref = { service: 'live', type: 'stream', id: '123' };++ const commentThread = (await call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref } })).thread;++ if (commentThread) await call(`/api/v1/comments/threads/${commentThread.id}/comments`, { method: 'POST', json: { body: 'A comment from N-1' } });++++ process.on('SIGTERM', () => { server.close(); try { require(path.join(dir, 'server', 'db')).closeDb(); } catch { /* */ } process.exit(0); });++ process.stdout.write(`${JSON.stringify({ n1: { url, token, ids: { thread: thread && thread.id, thread_slug: thread && thread.slug, paste: paste && paste.slug, comment_thread: commentThread && commentThread.id, comment_access: commentThread && (commentThread.access_id || commentThread.id) } } })}\n`);++})().catch((err) => { process.stderr.write(`${err.stack || err.message}\n`); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,855 @@++'use strict';++/**++ * N-1 harness (roadmap WS-P task 11, ADR-016's mixed-version window): the previous release's client++ * against this release's server, and the previous release's SQL against this release's schema.++ * The same file in Live, Chat, Media and Community; each repository's test/n-1/service.js says what++ * its clients are, how to boot and seed it, and where its SQL lives.++ *++ * scripts/n-1-record.js <ref> at release time: checks out <ref> (the release in production, which++ * is N-1 for the next deploy) into a temporary worktree, boots it, and++ * writes test/fixtures/n-1/{client,worker}.json++ * test/n-1.test.js in npm test and CI: boots this checkout on a database created with++ * N-1's schema and replays the fixture; no git history, no network++ *++ * Client calls are found statically in the N-1 client code (the call sites of `api(...)`, `fetch(...)`++ * and the service's other wrappers, with a literal or template path), made concrete with sample values,++ * and replayed against the booted N-1 server: every call anonymously, reads again signed in. What the++ * fixture keeps per call is what an old client depends on: the status, whether the answer is JSON,++ * and the response fields it reads (the JSON key paths whose every key the N-1 client code names, with++ * their types).++ *++ * SQL is every statement N-1 prepared while it served those calls, plus every literal SQL string in its++ * server code that prepares on its own schema. The N-1 schema is its sqlite_master after the replay.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const { execFileSync } = require('child_process');++++// ── Client calls: static extraction ──────────────────────────++++/** Reads a JS string/template literal at src[i] (a quote). → { end, parts: [{ text } | { expr }] } or null. */++function readLiteral(src, i) {++ const q = src[i];++ if (q !== '\'' && q !== '"' && q !== '`') return null;++ const parts = [];++ let text = '';++ let j = i + 1;++ while (j < src.length) {++ const c = src[j];++ if (c === '\\') { text += unescapeChar(src[j + 1]); j += 2; continue; }++ if (c === q) { if (text) parts.push({ text }); return { end: j + 1, parts }; }++ if (q !== '`' && c === '\n') return null;++ if (q === '`' && c === '$' && src[j + 1] === '{') {++ if (text) parts.push({ text });++ text = '';++ const close = skipBalanced(src, j + 1, '{', '}');++ if (close < 0) return null;++ parts.push({ expr: src.slice(j + 2, close).trim() });++ j = close + 1;++ continue;++ }++ text += c;++ j++;++ }++ return null;++}++++function unescapeChar(c) {++ return { n: '\n', t: '\t', r: '\r', '0': '\0' }[c] ?? c ?? '';++}++++/** Index of the bracket closing the one at src[i] (open), skipping strings and templates; -1 if none. */++function skipBalanced(src, i, open, close) {++ let depth = 0;++ for (let j = i; j < src.length; j++) {++ const c = src[j];++ if (c === '\'' || c === '"' || c === '`') {++ const lit = readLiteral(src, j);++ if (lit) { j = lit.end - 1; continue; }++ }++ if (c === open) depth++;++ else if (c === close) { depth--; if (depth === 0) return j; }++ }++ return -1;++}++++/** Top-level comma-separated arguments of a call whose '(' is at src[i]. */++function callArgs(src, i) {++ const end = skipBalanced(src, i, '(', ')');++ if (end < 0) return null;++ const args = [];++ let depth = 0; let start = i + 1;++ for (let j = i + 1; j < end; j++) {++ const c = src[j];++ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }++ if (c === '(' || c === '{' || c === '[') depth++;++ else if (c === ')' || c === '}' || c === ']') depth--;++ else if (c === ',' && depth === 0) { args.push(src.slice(start, j).trim()); start = j + 1; }++ }++ const last = src.slice(start, end).trim();++ if (last) args.push(last);++ return args;++}++++const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');++++/** src split at every top-level `sep` (outside strings and brackets). */++function splitTop(src, sep) {++ const out = [];++ let depth = 0; let start = 0;++ for (let j = 0; j < src.length; j++) {++ const c = src[j];++ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }++ if (c === '(' || c === '{' || c === '[') depth++;++ else if (c === ')' || c === '}' || c === ']') depth--;++ else if (c === sep && depth === 0) { out.push(src.slice(start, j)); start = j + 1; }++ }++ out.push(src.slice(start));++ return out.map((x) => x.trim());++}++++/**++ * The parts of a path expression: a literal, a template, or literals, templates and names joined with++ * `+` ('/api/x/' + id + '?a=1'); a name in `vars` stands for the path it was assigned. → parts or null++ */++function pathParts(expr, vars = new Map()) {++ const parts = [];++ for (const piece of splitTop(expr, '+')) {++ if (!piece) return null;++ const lit = /^['"`]/.test(piece) ? readLiteral(piece, 0) : null;++ if (lit && lit.end === piece.length) parts.push(...lit.parts.flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x])));++ else if (vars.has(piece)) parts.push(...vars.get(piece));++ else parts.push({ expr: piece });++ }++ const merged = [];++ for (const p of parts) {++ const last = merged[merged.length - 1];++ if (p.text != null && last && last.text != null) last.text += p.text; else merged.push({ ...p });++ }++ return merged;++}++++/** `esc(base)`, `encodeURIComponent(base)`: the name inside one escaping call. */++function unwrap(expr) {++ const m = /^(?:esc|enc|encodeURI|encodeURIComponent|String|escapeHtml|escHtml)\(\s*([A-Za-z_$][\w$]*)\s*\)$/.exec(String(expr).trim());++ return m ? m[1] : String(expr).trim();++}++++/** `var|let|const name = <path expression>` in a file, for names later calls use as a path base. */++function pathVars(src) {++ const vars = new Map();++ for (const m of src.matchAll(/(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*(?=['"`])/g)) {++ let depth = 0; let end = src.length;++ for (let j = m.index + m[0].length; j < src.length; j++) {++ const c = src[j];++ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }++ if (c === '(' || c === '{' || c === '[') depth++;++ else if (c === ')' || c === '}' || c === ']') { if (depth === 0) { end = j; break; } depth--; } else if (depth === 0 && (c === ';' || c === '\n' || c === ',')) { end = j; break; }++ }++ const parts = pathParts(src.slice(m.index + m[0].length, end).trim(), vars);++ if (parts && parts[0] && parts[0].text && parts[0].text.startsWith('/') && !parts[0].text.startsWith('//')) vars.set(m[1], parts);++ }++ return vars;++}++++/**++ * Every call site of the service's request functions whose path is a literal, a template or a++ * concatenation starting with one (or with a variable assigned one).++ * callers: [{ name: 'api', prefix: '/api', method?: 'POST', methodArg?: 1 }]++ * strip: template expressions dropped when a path starts with them (e.g. 'API', 'location.origin')++ * keep(pathname, method, file): whether a call is this server's++ * → { calls: [{ method, template, parts, from: [file] }], dynamic: calls with a computed path }++ */++function extractCalls({ files, callers, strip = [], keep = (p) => true, forms = false }) {++ const found = new Map();++ let dynamic = 0;++ for (const { name: file, text: src } of files) {++ const vars = pathVars(src);++ for (const caller of callers) {++ const re = new RegExp(`(?<![\\w$.])${esc(caller.name)}\\s*\\(`, 'g');++ let m;++ while ((m = re.exec(src))) {++ const open = m.index + m[0].length - 1;++ const args = callArgs(src, open);++ if (!args || !args.length) continue;++ // An SDK-style call: call({ method: 'POST', path: `/api/x/${id}`, … }).++ let pathExpr = args[0];++ let objMethod = null;++ if (caller.object) {++ if (!/^\{[\s\S]*\}$/.test(args[0])) continue; // some other function of that name++ const entries = splitTop(args[0].slice(1, -1), ',');++ const entry = (k) => { const e = entries.find((x) => new RegExp(`^${k}\\s*:`).test(x)); return e ? e.replace(new RegExp(`^${k}\\s*:\\s*`), '') : null; };++ pathExpr = entry('path');++ const me = entry('method');++ const ml = me && /^['"`]/.test(me) ? readLiteral(me, 0) : null;++ if (ml && ml.parts.length === 1 && ml.parts[0].text) objMethod = ml.parts[0].text;++ if (!pathExpr) continue;++ }++ let parts = pathParts(pathExpr, vars);++ if (!parts) { dynamic++; continue; }++ while (parts.length && parts[0].expr && strip.includes(parts[0].expr)) parts.shift();++ if (!parts.length || !parts[0].text || !parts[0].text.startsWith('/') || parts[0].text.startsWith('//')) {++ if (parts.length && parts[0].expr) dynamic++;++ continue;++ }++ parts = caller.prefix ? [{ text: caller.prefix }, ...parts] : parts;++ if (parts[1] && parts[0].text != null && parts[1].text != null) parts = [{ text: parts[0].text + parts[1].text }, ...parts.slice(2)];++ // `/api${path}`: a wrapper passing a path through, not a call.++ if (parts.some((p, k) => p.expr && k > 0 && parts[k - 1].text != null && !parts.slice(0, k).some((x) => x.text && x.text.includes('?')) && /[A-Za-z0-9]$/.test(parts[k - 1].text))) { dynamic++; continue; }++ const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('').replace(/^([^?#]*)\/\//, '$1/');++ let method = objMethod || caller.method || 'GET';++ if (caller.object) { /* the method was in the object */ } else if (caller.methodArg != null) {++ const a = args[caller.methodArg];++ const ml = a && /^['"`]/.test(a) ? readLiteral(a, 0) : null;++ if (ml && ml.parts.length === 1 && ml.parts[0].text) method = ml.parts[0].text;++ } else {++ const opts = args.slice(1).join(',');++ const mm = /\bmethod\s*:\s*['"`]([A-Za-z]+)['"`]/.exec(opts);++ if (mm) method = mm[1];++ }++ method = method.toUpperCase();++ if (!keep(template.split('?')[0], method, file)) continue;++ const key = `${method} ${template}`;++ if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });++ found.get(key).from.add(file);++ }++ }++ }++ // Forms the pages post (or get) to: <form action="/x/${id}" method="post"> in markup or a template.++ if (forms) {++ for (const { name: file, text: src } of files) {++ const vars = pathVars(src);++ for (const m of src.matchAll(/<form\b/gi)) {++ const end = tagEnd(src, m.index);++ if (end < 0) continue;++ const tag = src.slice(m.index, end);++ const action = attr(tag, 'action');++ if (!action || action.startsWith('//') || /^[a-z]+:/i.test(action)) continue;++ const parts = templateParts(action).flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x]));++ if (!parts.length || parts[0].text == null || !parts[0].text.startsWith('/')) continue;++ const methodAttr = attr(tag, 'method');++ const method = /^post$/i.test(methodAttr || '') ? 'POST' : 'GET';++ const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('');++ if (!keep(template.split('?')[0], method, file)) continue;++ const key = `${method} ${template}`;++ if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });++ found.get(key).from.add(file);++ }++ }++ }++ const calls = [...found.values()].map((c) => ({ ...c, from: [...c.from].sort() }))++ .sort((a, b) => (a.template < b.template ? -1 : a.template > b.template ? 1 : a.method < b.method ? -1 : 1));++ return { calls, dynamic };++}++++/** Where an HTML tag that starts at src[i] ends ('>' outside quotes and ${…}); -1 if it does not. */++function tagEnd(src, i) {++ let q = null;++ for (let j = i; j < src.length; j++) {++ const c = src[j];++ if (c === '$' && src[j + 1] === '{') { const close = skipBalanced(src, j + 1, '{', '}'); if (close < 0) return -1; j = close; continue; }++ if (q) { if (c === q) q = null; continue; }++ if (c === '"' || c === '\'') q = c;++ else if (c === '>') return j + 1;++ }++ return -1;++}++++/** An attribute's raw value in a tag (quoted, may hold ${…}). */++function attr(tag, name) {++ const m = new RegExp(`\\s${name}\\s*=\\s*(["'])`, 'i').exec(tag);++ if (!m) return null;++ const q = m[1];++ let out = '';++ for (let j = m.index + m[0].length; j < tag.length; j++) {++ const c = tag[j];++ if (c === '$' && tag[j + 1] === '{') { const close = skipBalanced(tag, j + 1, '{', '}'); if (close < 0) return null; out += tag.slice(j, close + 1); j = close; continue; }++ if (c === q) return out;++ out += c;++ }++ return null;++}++++/** Text with ${expr} holes → parts. */++function templateParts(text) {++ const parts = [];++ let buf = '';++ for (let j = 0; j < text.length; j++) {++ if (text[j] === '$' && text[j + 1] === '{') {++ const close = skipBalanced(text, j + 1, '{', '}');++ if (close < 0) break;++ if (buf) parts.push({ text: buf });++ buf = '';++ parts.push({ expr: text.slice(j + 2, close).trim() });++ j = close;++ continue;++ }++ buf += text[j];++ }++ if (buf) parts.push({ text: buf.replace(/&/g, '&') });++ return parts;++}++++/**++ * A concrete path for a template: each ${expr} becomes the first sample whose pattern matches the++ * expression text, else '1' in the path and '' in the query.++ */++function concretePath(parts, samples = []) {++ let out = '';++ for (const p of parts) {++ if (p.text != null) { out += p.text; continue; }++ const inQuery = out.includes('?');++ // `${on ? 'mute' : 'unmute'}`: the first branch.++ const branch = /\?\s*(['"`])([^'"`]*)\1\s*:/.exec(p.expr);++ if (branch) { out += branch[2]; continue; }++ const hit = samples.find(([re]) => re.test(p.expr));++ let v = hit ? hit[1] : (inQuery ? '' : '1');++ if (typeof v === 'function') v = v(p.expr, inQuery);++ // '@name': a value the booted server seeded (a random slug); filled in when the call is sent.++ if (/^@\w+$/.test(String(v))) { out += `{${v}}`; continue; }++ out += inQuery ? String(v) : encodeURIComponent(String(v)).replace(/%2F/gi, '/');++ }++ out = out.replace(/^([^?#]*)\/\/+/, '$1/').replace(/[?&]+$/, '').replace(/\?&+/, '?');++ return out;++}++++/**++ * The same-origin URLs a served page makes the browser fetch or offers to follow: src, href, poster,++ * data-src and form actions (with their method). `origins` are absolute origins that count as this++ * site (e.g. its public URL). → [{ method, path }]++ */++function pageLinks(html, origins = []) {++ const out = new Map();++ const add = (method, raw) => {++ let u = String(raw || '').trim().replace(/&/g, '&');++ if (!u || /^(#|mailto:|javascript:|data:|tel:|blob:)/i.test(u)) return;++ const o = origins.find((x) => u.startsWith(`${x}/`) || u === x);++ if (o) u = u.slice(o.length) || '/';++ if (!u.startsWith('/') || u.startsWith('//')) return;++ u = u.split('#')[0];++ out.set(`${method} ${u}`, { method, path: u });++ };++ for (const m of String(html).matchAll(/<([a-zA-Z][\w-]*)\b([^>]*)>/g)) {++ const tag = m[1].toLowerCase(); const attrs = m[2];++ const get = (n) => { const a = new RegExp(`\\s${n}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs); return a ? (a[1] != null ? a[1] : a[2]) : null; };++ if (tag === 'form') { const a = get('action'); if (a != null) add(/^post$/i.test(get('method') || '') ? 'POST' : 'GET', a); continue; }++ for (const n of ['src', 'href', 'poster', 'data-src']) { const v = get(n); if (v != null) add('GET', v); }++ }++ return [...out.values()];++}++++/** A path with the seeded ids that differ per run (random slugs, keys) put back as {@name} slots. */++function unfillPath(p, ids = {}) {++ let out = String(p);++ for (const [name, v] of Object.entries(ids || {})) {++ const s = v == null ? '' : String(v);++ if (s.length < 6 || /^\d+$/.test(s)) continue;++ out = out.split(encodeURIComponent(s)).join(`{@${name}}`).split(s).join(`{@${name}}`);++ }++ return out;++}++++/** A recorded path with its {@name} slots filled from the booted server's seeded ids. */++function fillPath(p, ids = {}) {++ return String(p).replace(/\{@(\w+)\}/g, (m, name) => encodeURIComponent(ids[name] == null ? '0' : String(ids[name])));++}++++/** Property names the client code reads: `.name`, `?.name`, `['name']`, 'name' literals, destructured names. */++function clientIdentifiers(texts) {++ const ids = new Set();++ for (const t of texts) {++ for (const m of t.matchAll(/\??\.\s*([A-Za-z_$][\w$]*)/g)) ids.add(m[1]);++ for (const m of t.matchAll(/['"`]([A-Za-z_$][\w$]*)['"`]/g)) ids.add(m[1]);++ for (const m of t.matchAll(/\{([^{}()]*)\}\s*(?:=[^=>]|\)\s*=>|\s*\))/g)) {++ for (const part of m[1].split(',')) {++ const id = /^\s*(?:\.\.\.)?\s*([A-Za-z_$][\w$]*)/.exec(part);++ if (id) ids.add(id[1]);++ }++ }++ }++ return ids;++}++++// ── Response shapes ──────────────────────────────────────────++++function typeOf(v) {++ if (v === null) return 'null';++ if (Array.isArray(v)) return 'array';++ return typeof v;++}++++/**++ * The key paths of a JSON value → { 'a': 'object', 'a.b': 'string', 'list[]': 'object', 'list[].x': 'number' }.++ * Array elements are merged: a path is present when any element has it; its types are all it takes.++ */++function flatten(value, maxDepth = 5) {++ const out = {};++ const add = (p, t) => { if (!out[p]) out[p] = new Set(); out[p].add(t); };++ const walk = (v, prefix, depth) => {++ if (depth > maxDepth) return;++ if (Array.isArray(v)) {++ for (const el of v) { add(`${prefix}[]`, typeOf(el)); walk(el, `${prefix}[]`, depth + 1); }++ } else if (v && typeof v === 'object') {++ for (const [k, x] of Object.entries(v)) {++ const p = prefix ? `${prefix}.${k}` : k;++ add(p, typeOf(x));++ walk(x, p, depth + 1);++ }++ }++ };++ walk(value, '', 0);++ return out;++}++++/** The key paths of `value` an old client reads: every key along the path is one the client names. */++function readsOf(value, ids) {++ const flat = flatten(value);++ const reads = {};++ for (const [p, types] of Object.entries(flat)) {++ const keys = p.split('.').map((k) => k.replace(/\[\]$/g, '')).filter(Boolean);++ if (!keys.length || !keys.every((k) => ids.has(k))) continue;++ const t = [...types].filter((x) => x !== 'null').sort();++ reads[p] = t.length ? t.join('|') : 'null';++ }++ return reads;++}++++/** What `reads` (recorded from N-1) finds missing or retyped in `value` (from N). */++function readProblems(reads, value) {++ const flat = flatten(value);++ const problems = [];++ // A path under an array N answers empty (or null) cannot be read by the old client either.++ const emptyArray = (p) => {++ const segs = p.split('.');++ for (let i = 1; i <= segs.length; i++) {++ const head = segs.slice(0, i).join('.');++ if (!head.endsWith('[]')) continue;++ const arr = head.slice(0, -2);++ if (!flat[head] && (arr === '' ? Array.isArray(value) : flat[arr] && !flat[arr].has('object'))) return true;++ }++ return false;++ };++ const nullParent = (p) => {++ const segs = p.split('.');++ for (let i = 1; i < segs.length; i++) {++ const head = segs.slice(0, i).join('.').replace(/\[\]$/, '');++ if (flat[head] && [...flat[head]].every((t) => t === 'null')) return true;++ }++ return false;++ };++ for (const [p, want] of Object.entries(reads)) {++ if (emptyArray(p) || nullParent(p)) continue;++ const have = flat[p];++ if (!have) { problems.push(`${p} is gone`); continue; }++ if (want === 'null') continue;++ const now = [...have].filter((t) => t !== 'null');++ if (!now.length) continue;++ const was = want.split('|');++ if (!now.some((t) => was.includes(t))) problems.push(`${p} was ${want}, now ${now.join('|')}`);++ }++ return problems;++}++++/** Whether N's status keeps N-1's promise: any 2xx for a 2xx, the same code otherwise; a 5xx only needs the route. */++function statusCompatible(was, now) {++ if (was >= 200 && was < 300) return now >= 200 && now < 300;++ if (was >= 500) return now !== 404 && now !== 405;++ return was === now;++}++++// ── Replaying calls ──────────────────────────────────────────++++function kindOf(contentType, text) {++ const ct = String(contentType || '').toLowerCase();++ if (ct.includes('text/event-stream')) return 'sse';++ if (ct.includes('json')) return 'json';++ if (ct.includes('html')) return 'html';++ if (!text) return 'empty';++ if (ct.startsWith('text/')) return 'text';++ return 'other';++}++++/**++ * Sends one call. → { status, kind, json, location }. Redirects are not followed (they may leave the++ * sandbox); an event stream is not read past its headers.++ */++async function send(baseUrl, { method, path: p, headers = {}, body }, { timeoutMs = 8000 } = {}) {++ const ac = new AbortController();++ const timer = setTimeout(() => ac.abort(), timeoutMs);++ try {++ const hasBody = body !== undefined && method !== 'GET' && method !== 'HEAD';++ const res = await fetch(new URL(p, baseUrl), {++ method,++ redirect: 'manual',++ signal: ac.signal,++ headers: { accept: 'application/json', ...(hasBody ? { 'content-type': 'application/json' } : {}), ...headers },++ body: hasBody ? JSON.stringify(body) : undefined,++ });++ const ct = res.headers.get('content-type');++ if (/event-stream/i.test(ct || '')) { ac.abort(); return { status: res.status, kind: 'sse', json: null }; }++ const text = await res.text();++ const kind = kindOf(ct, text);++ let json = null;++ if (kind === 'json') { try { json = JSON.parse(text); } catch { json = null; } }++ const html = kind === 'html' ? text.slice(0, 1 << 20) : null;++ const loc = res.headers.get('location');++ // Where a redirect goes, without its query (a sign-in `next` or `state` differs every run).++ return { status: res.status, kind, json, html, location: loc ? loc.replace(/^https?:\/\/[^/]+/, '').split('?')[0] : null };++ } catch (err) {++ return { status: 0, kind: 'error', json: null, error: err.name === 'AbortError' ? 'timeout' : err.message };++ } finally {++ clearTimeout(timer);++ }++}++++/**++ * The request list for a set of extracted calls: every call anonymously, GETs also signed in.++ * Reads first, then writes (with an empty JSON body), so a write cannot change what a read sees.++ */++function requestsFor(calls, samples, { signedIn = true } = {}) {++ const seen = new Set();++ const reqs = [];++ for (const c of calls) {++ const p = concretePath(c.parts, samples);++ for (const auth of c.method === 'GET' && signedIn ? ['anon', 'user'] : ['anon']) {++ const key = `${c.method} ${p} ${auth}`;++ if (seen.has(key)) continue;++ seen.add(key);++ reqs.push({ method: c.method, path: p, auth, from: c.from });++ }++ }++ const rank = (r) => (r.method === 'GET' ? 0 : 1);++ return reqs.sort((a, b) => rank(a) - rank(b));++}++++/**++ * What the server answers a path nobody routes (per method): an HTML 404 is always "no route"; a++ * JSON catch-all 404 (`{ error: 'Not found' }`) is recognised by its body. → [{ method, status, kind, json }]++ */++async function notFoundProbe(baseUrl, headers = {}) {++ const out = [];++ for (const method of ['GET', 'POST']) {++ for (const p of ['/api/n-1-no-such-route', '/n-1-no-such-page']) {++ const got = await send(baseUrl, { method, path: p, headers, body: method === 'GET' ? undefined : {} });++ if (got.status === 404) out.push({ method, status: got.status, kind: got.kind, json: got.json });++ }++ }++ return out;++}++++/** Whether an answer is the server's unknown-path 404 rather than a route's own. */++function isNoRoute(got, probe = []) {++ if (got.status !== 404) return false;++ if (got.kind !== 'json') return true;++ return probe.some((fp) => fp.kind === 'json' && JSON.stringify(fp.json) === JSON.stringify(got.json));++}++++/** The failures of one replayed call against its recorded outcome (probe: N's unknown-path answers). */++function callProblems(rec, got, probe = []) {++ const problems = [];++ if (got.status === 0) return [`no answer (${got.error})`];++ if (isNoRoute(got, probe)) return ['the route is gone (it answers as an unknown path)'];++ if (!statusCompatible(rec.status, got.status)) problems.push(`answered ${got.status}, N-1 answered ${rec.status}`);++ else if (rec.kind === 'json' && got.kind !== 'json' && rec.status < 500) problems.push(`answered ${got.kind}, N-1 answered JSON`);++ if (rec.location && got.location && rec.status >= 300 && rec.status < 400 && got.location.split('?')[0] !== rec.location.split('?')[0]) problems.push(`redirects to ${got.location}, N-1 to ${rec.location}`);++ if (rec.reads && got.json != null && got.status >= 200 && got.status < 300) problems.push(...readProblems(rec.reads, got.json));++ return problems;++}++++// ── WebSockets ───────────────────────────────────────────────++++/**++ * The messages a client sends over its sockets: `x.send(JSON.stringify({ type: 'join', … }))` and++ * `helper({ type: … })` for each helper name. → [{ type, keys: [...] }] (keys merged per type)++ */++function wsSends(files, helpers = []) {++ const byType = new Map();++ const add = (obj) => {++ const t = obj.trim();++ if (!t.startsWith('{') || !t.endsWith('}')) return;++ let type = null; const keys = [];++ for (const entry of splitTop(t.slice(1, -1), ',')) {++ if (!entry || entry.startsWith('...')) continue;++ const kv = /^(?:['"]?)([A-Za-z_$][\w$-]*)(?:['"]?)\s*(?::\s*([\s\S]*))?$/.exec(entry);++ if (!kv) continue;++ if (kv[1] === 'type') {++ const lit = kv[2] && /^['"`]/.test(kv[2].trim()) ? readLiteral(kv[2].trim(), 0) : null;++ if (lit && lit.parts.length === 1 && lit.parts[0].text) type = lit.parts[0].text;++ } else keys.push(kv[1]);++ }++ if (!type) return;++ if (!byType.has(type)) byType.set(type, new Set());++ for (const k of keys) byType.get(type).add(k);++ };++ for (const { text } of files) {++ for (const m of text.matchAll(/\.send\(\s*JSON\.stringify\(/g)) {++ const args = callArgs(text, m.index + m[0].length - 1);++ if (args && args[0]) add(args[0]);++ }++ for (const name of helpers) {++ for (const m of text.matchAll(new RegExp(`(?<![\\w$.])${esc(name)}\\s*\\(\\s*(?=\\{)`, 'g'))) {++ const args = callArgs(text, m.index + m[0].indexOf('('));++ if (args && args[0]) add(args[0]);++ }++ }++ }++ return [...byType].map(([type, keys]) => ({ type, keys: [...keys].sort() })).sort((a, b) => (a.type < b.type ? -1 : 1));++}++++/** The message types a client acts on: `case 'x':` and `.type === 'x'`. */++function wsHandled(files) {++ const types = new Set();++ for (const { text } of files) {++ for (const m of text.matchAll(/\bcase\s+['"]([\w:.-]+)['"]\s*:/g)) types.add(m[1]);++ for (const m of text.matchAll(/\.type\s*[!=]==?\s*['"]([\w:.-]+)['"]/g)) types.add(m[1]);++ }++ return types;++}++++/**++ * One scripted socket session: connect to `url`, send each message in turn and wait until the server++ * has been quiet for `quietMs`. → every message received (parsed JSON objects)++ */++async function wsSession({ url, headers = {}, messages, quietMs = 250, maxMs = 3000 }) {++ const WebSocket = require('ws');++ const ws = new WebSocket(url, { headers });++ const got = [];++ let last = Date.now();++ ws.on('message', (d) => { try { const m = JSON.parse(String(d)); if (m && typeof m === 'object') got.push(m); } catch { /* not JSON */ } last = Date.now(); });++ await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); ws.once('unexpected-response', (req, res) => reject(new Error(`upgrade refused: ${res.statusCode}`))); });++ const settle = async () => {++ const start = Date.now();++ last = Date.now();++ while (Date.now() - last < quietMs && Date.now() - start < maxMs) await new Promise((r) => setTimeout(r, 25));++ };++ await settle();++ for (const m of messages) {++ if (ws.readyState !== 1) break;++ ws.send(JSON.stringify(m));++ await settle();++ }++ await new Promise((resolve) => { ws.once('close', resolve); ws.close(); setTimeout(resolve, 500); });++ return got;++}++++/** The messages to send for a client's sends, in `order` (then the rest), with keys filled from `values`. */++function wsMessages(sends, { order = [], values = {} } = {}) {++ const rank = (t) => { const i = order.indexOf(t); return i < 0 ? order.length : i; };++ return sends.slice().sort((a, b) => rank(a.type) - rank(b.type) || (a.type < b.type ? -1 : 1))++ .map((s) => {++ const m = { type: s.type };++ for (const k of s.keys) if (values[k] !== undefined) m[k] = values[k];++ return m;++ });++}++++/** Received messages grouped by type, for the types the client handles → { type: reads } */++function wsReads(received, handled, ids) {++ const byType = new Map();++ for (const m of received) {++ if (typeof m.type !== 'string' || !handled.has(m.type)) continue;++ if (!byType.has(m.type)) byType.set(m.type, []);++ byType.get(m.type).push(m);++ }++ const out = {};++ for (const [t, list] of [...byType].sort((a, b) => (a[0] < b[0] ? -1 : 1))) out[t] = readsOf(list, ids);++ return out;++}++++/** What `recorded` ({ type: reads }) finds missing in the messages N sent. */++function wsProblems(recorded, received) {++ const problems = [];++ for (const [type, reads] of Object.entries(recorded)) {++ const list = received.filter((m) => m.type === type);++ if (!list.length) { problems.push(`no '${type}' message any more`); continue; }++ problems.push(...readProblems(reads, list).map((p) => `'${type}': ${p.replace(/^\[\]\.?/, '')}`));++ }++ return problems;++}++++// ── SQL ──────────────────────────────────────────────────────++++const SQL_START = /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i;++++/** Every plain JS string literal in the files (a template only without ${}), comments skipped. */++function stringLiterals(files) {++ const out = [];++ for (const { text } of files) {++ for (let i = 0; i < text.length; i++) {++ const c = text[i];++ if (c === '/' && text[i + 1] === '/') { const nl = text.indexOf('\n', i); i = nl < 0 ? text.length : nl; continue; }++ if (c === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; continue; }++ if (c !== '\'' && c !== '"' && c !== '`') continue;++ const lit = readLiteral(text, i);++ if (!lit) continue;++ i = lit.end - 1;++ if (lit.parts.length === 1 && lit.parts[0].text != null) out.push(lit.parts[0].text);++ }++ }++ return out;++}++++/** The literals that read like one SQL statement (DML). */++function sqlLiterals(files) {++ const out = new Set();++ for (const t of stringLiterals(files)) {++ const sql = normalizeSql(t);++ if (SQL_START.test(sql) && /\b(FROM|INTO|SET|VALUES)\b/i.test(sql)) out.add(sql);++ }++ return [...out];++}++++/** SQL without its comments (-- to the end of a line, and block comments), outside quoted text. */++function stripSqlComments(sql) {++ let out = '';++ const s = String(sql);++ for (let i = 0; i < s.length; i++) {++ const c = s[i];++ if (c === '\'' || c === '"') {++ const j = s.indexOf(c, i + 1);++ const end = j < 0 ? s.length : j + 1;++ out += s.slice(i, end); i = end - 1; continue;++ }++ if (c === '-' && s[i + 1] === '-') { const nl = s.indexOf('\n', i); i = nl < 0 ? s.length : nl - 1; continue; }++ if (c === '/' && s[i + 1] === '*') { const e = s.indexOf('*/', i + 2); i = e < 0 ? s.length : e + 1; out += ' '; continue; }++ out += c;++ }++ return out;++}++++function normalizeSql(sql) {++ return stripSqlComments(sql).replace(/\s+/g, ' ').trim();++}++++/**++ * The DDL N-1 runs itself when it first needs a table or column (a module's CREATE TABLE IF NOT EXISTS,++ * a boot-time ALTER TABLE … ADD COLUMN) that `db` does not have yet. Each statement is applied to `db`++ * as it is found to apply; → the applied statements, tables first, in order.++ */++function lazyDDL(files, db) {++ const tables = () => new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));++ const columns = (t) => { try { return new Set(db.prepare(`PRAGMA table_xinfo("${t}")`).all().map((c) => c.name.toLowerCase())); } catch { return new Set(); } };++ const lits = stringLiterals(files).map(stripSqlComments);++ const applied = [];++ const apply = (ddl) => { try { db.exec(ddl); applied.push(ddl); return true; } catch { return false; } };++ for (const text of lits) {++ for (const m of text.matchAll(/CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+["`]?(\w+)["`]?\s*\(/gi)) {++ // A rebuild's scratch table (x_new) is the migration's own business, not a table N-1 uses.++ if (tables().has(m[1]) || /_(new|old|tmp|backup)$/i.test(m[1])) continue;++ const end = skipBalanced(text, m.index + m[0].length - 1, '(', ')');++ if (end >= 0) apply(normalizeSql(text.slice(m.index, end + 1)));++ }++ }++ for (const text of lits) {++ const m = /^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+(?:COLUMN\s+)?["`]?(\w+)["`]?[^;]*$/i.exec(text.trim().replace(/;$/, ''));++ if (!m || !tables().has(m[1]) || columns(m[1]).has(m[2].toLowerCase())) continue;++ apply(normalizeSql(text).replace(/;$/, ''));++ }++ return applied;++}++++/** Every file under dir with one of the extensions, as { name (relative to root), text }. */++function readTree(root, dirs, exts = ['.js']) {++ const out = [];++ const walk = (d) => {++ let entries = [];++ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }++ for (const e of entries) {++ const p = path.join(d, e.name);++ if (e.isDirectory()) { if (e.name !== 'node_modules' && !e.name.startsWith('.')) walk(p); } else if (exts.some((x) => e.name.endsWith(x))) out.push({ name: path.relative(root, p), text: fs.readFileSync(p, 'utf8') });++ }++ };++ for (const d of dirs) {++ const abs = path.join(root, d);++ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push({ name: d, text: fs.readFileSync(abs, 'utf8') });++ else walk(abs);++ }++ return out.sort((a, b) => (a.name < b.name ? -1 : 1));++}++++/** The schema as DDL, tables first (no internal or FTS shadow tables), then indexes, triggers and views. */++function schemaDDL(db) {++ const shadow = new Set(db.prepare('PRAGMA table_list').all().filter((t) => t.type === 'shadow').map((t) => t.name));++ const rows = db.prepare("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'").all()++ .filter((r) => !shadow.has(r.name));++ const order = { table: 0, index: 1, view: 2, trigger: 3 };++ return rows.sort((a, b) => order[a.type] - order[b.type] || (a.name < b.name ? -1 : 1)).map((r) => r.sql);++}++++/** Prepares every statement on db (a missing table or column fails the prepare). → [{ sql, error }] */++function prepareProblems(db, statements) {++ const problems = [];++ for (const sql of statements) {++ try { db.prepare(sql); } catch (e) { problems.push({ sql, error: e.message }); }++ }++ return problems;++}++++/**++ * INSERTs of N-1 that N would refuse: a column N made NOT NULL without a default that the INSERT does++ * not name. → [{ sql, error }]++ */++function insertProblems(db, statements) {++ const problems = [];++ const cols = new Map();++ const required = (table) => {++ if (!cols.has(table)) {++ let info = [];++ try { info = db.prepare(`PRAGMA table_xinfo("${table.replace(/"/g, '""')}")`).all(); } catch { info = []; }++ const pkInt = info.filter((c) => c.pk).length === 1 && info.find((c) => c.pk && /^INTEGER$/i.test(c.type));++ cols.set(table, info.filter((c) => c.notnull && c.dflt_value == null && !c.hidden && !(pkInt && c.pk)).map((c) => c.name));++ }++ return cols.get(table);++ };++ for (const sql of statements) {++ const m = /^\s*(?:INSERT|REPLACE)(?:\s+OR\s+\w+)?\s+INTO\s+["`]?(\w+)["`]?\s*\(([^)]*)\)\s*(VALUES|SELECT)/i.exec(sql);++ if (!m) continue;++ const named = new Set(m[2].split(',').map((c) => c.trim().replace(/^["`]|["`]$/g, '').toLowerCase()));++ const missing = required(m[1]).filter((c) => !named.has(c.toLowerCase()));++ if (missing.length) problems.push({ sql, error: `${m[1]}.${missing.join(', ')} is NOT NULL without a default, and N-1 does not set it` });++ }++ return problems;++}++++// ── Git ──────────────────────────────────────────────────────++++/** A detached worktree of `ref` in a temp directory sharing root's node_modules → { dir, sha, remove() }. */++function worktree(root, ref) {++ const sha = execFileSync('git', ['-C', root, 'rev-parse', '--verify', `${ref}^{commit}`], { encoding: 'utf8' }).trim();++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-'));++ const dir = path.join(tmp, 'release');++ execFileSync('git', ['-C', root, 'worktree', 'add', '--detach', dir, sha], { stdio: 'ignore' });++ fs.symlinkSync(path.join(root, 'node_modules'), path.join(dir, 'node_modules'), 'dir');++ return {++ dir, sha,++ remove() {++ try { execFileSync('git', ['-C', root, 'worktree', 'remove', '--force', dir], { stdio: 'ignore' }); } catch { /* */ }++ fs.rmSync(tmp, { recursive: true, force: true });++ },++ };++}++++/** Files of another repository at a commit (git show), e.g. Live's chat widget for Chat. → [{ name, text }] */++function gitFiles(repo, ref, dirs, exts = ['.js']) {++ const names = execFileSync('git', ['-C', repo, 'ls-tree', '-r', '--name-only', ref, '--', ...dirs], { encoding: 'utf8', maxBuffer: 64 << 20 })++ .split('\n').filter((n) => n && exts.some((x) => n.endsWith(x)) && !n.includes('node_modules/'));++ return names.sort().map((n) => ({ name: `${path.basename(repo)}:${n}`, text: execFileSync('git', ['-C', repo, 'show', `${ref}:${n}`], { encoding: 'utf8', maxBuffer: 64 << 20 }) }));++}++++// ── Reporting ────────────────────────────────────────────────++++function summarize(problems, max = 40) {++ const lines = problems.slice(0, max);++ if (problems.length > max) lines.push(`… and ${problems.length - max} more`);++ return lines.join('\n');++}++++module.exports = {++ readLiteral, callArgs, splitTop, pathParts, extractCalls, concretePath, fillPath, unfillPath, pageLinks, clientIdentifiers,++ flatten, readsOf, readProblems, statusCompatible,++ send, requestsFor, notFoundProbe, isNoRoute, callProblems,++ wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,++ stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,++ worktree, gitFiles, summarize,++};nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++/**++ * Loaded with `node -r` into a release booted by test/n-1/service.js (N-1 at record time, N in the++ * test), from that release's directory. With N1_SQL_OUT set, every SQL text the process prepares or++ * executes on the community database (COMMUNITY_DB_PATH, read when the statement runs) is written++ * there as JSON when it exits.++ */++const fs = require('fs');++const path = require('path');++++const out = process.env.N1_SQL_OUT;++if (out) {++ const Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3'));++ const seen = new Set();++ const note = (db, sql) => {++ try { if (process.env.COMMUNITY_DB_PATH && path.resolve(db.name) === path.resolve(process.env.COMMUNITY_DB_PATH)) seen.add(String(sql)); } catch { /* */ }++ };++ const prepare = Database.prototype.prepare;++ Database.prototype.prepare = function (sql, ...rest) { note(this, sql); return prepare.call(this, sql, ...rest); };++ const exec = Database.prototype.exec;++ Database.prototype.exec = function (sql, ...rest) { note(this, sql); return exec.call(this, sql, ...rest); };++ process.on('exit', () => { try { fs.writeFileSync(out, JSON.stringify([...seen])); } catch { /* */ } });++}nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++/**++ * Community's side of the N-1 harness (test/n-1/harness.js): its clients, how a release boots and is++ * seeded, where its SQL lives. Used by scripts/n-1-record.js (on N-1, in a temporary worktree) and by++ * test/n-1.test.js (on this checkout), so both boot and seed the same way.++ *++ * Community's clients are its pages: public/js/community.js, the forms and scripts the templates in++ * server/render write (an open tab posts the form it was served), the links, scripts and forms of the++ * pages N-1 served (crawled), and the openvibe-sdk community client (pastes) as that release installed it.++ *++ * A release boots in a child process (test/n-1/boot-child.js) with its own Live, Network and Media++ * mocks, the pastes authority on, and its database at COMMUNITY_DB_PATH; sign-in is the ov_token++ * cookie the Network mock signs.++ */++const path = require('path');++const { spawn } = require('child_process');++const { readTree } = require('./harness');++++const PRELOAD = path.join(__dirname, 'preload.js');++const CHILD = path.join(__dirname, 'boot-child.js');++++function baseEnv(extra) {++ return { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR || '/tmp', NODE_ENV: 'test', ...extra };++}++++module.exports = {++ service: 'community',++++ clientFiles(dir) {++ return readTree(dir, ['public/js', 'server/render', 'node_modules/openvibe-sdk/src/community.js'], ['.js']);++ },++ callers: [++ { name: 'api' },++ { name: 'fetch' },++ { name: 'call', object: true },++ ],++ forms: true,++ keep: () => true,++ /** Pages an open tab may hold (anonymous): every link, script and form in them is replayed too. */++ crawl: ['/', '/pastes', '/p/{@paste}', '/s/general', '/s/general/t/n-1-thread', '/c/{@comment_access}', '/search', '/pulse', '/new', '/my'],++ origins: ['https://openvibe.community'],++ /** Values for template expressions, first match wins; '@name' is a seeded row (boot-child.js ids). */++ samples: [++ [/space/i, 'general'],++ [/thread\.slug/i, 'n-1-thread'],++ [/access_?id/i, '@comment_access'],++ [/comment_?id/i, '1'],++ [/slug|data-copy-content|data-delete/i, '@paste'],++ [/user_?name/i, 'n1star'],++ [/id\)*$/i, '1'],++ ],++++ sqlDirs: ['server'],++ ledgerTables: [],++++ /** Community migrates when it opens its database (server/db.js openDb); nothing to do first. */++ seed() {},++++ /** Boots the release in `dir` on dbPath → { url, ids, headers(auth), close() }. */++ async boot({ dir, dbPath, sqlOut = '' }) {++ const child = spawn(process.execPath, ['-r', PRELOAD, CHILD], {++ cwd: dir,++ env: baseEnv({ N1_DB: dbPath, N1_SQL_OUT: sqlOut }),++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let log = '';++ let info = null;++ const ready = new Promise((resolve, reject) => {++ child.stdout.on('data', (c) => {++ log = (log + c).slice(-20000);++ const line = String(c).split('\n').find((l) => l.startsWith('{"n1":'));++ if (line && !info) { info = JSON.parse(line).n1; resolve(); }++ });++ child.on('exit', () => reject(new Error(`the release in ${dir} exited while booting:\n${log.slice(-3000)}`)));++ });++ child.stderr.on('data', (c) => { log = (log + c).slice(-20000); });++ const exited = new Promise((resolve) => child.on('exit', resolve));++ const timer = setTimeout(() => child.kill('SIGKILL'), 60000);++ await ready;++ clearTimeout(timer);++ return {++ url: info.url,++ ids: info.ids,++ log: () => log,++ headers: (auth) => (auth === 'user' ? { cookie: `ov_token=${info.token}` } : {}),++ async close() {++ if (child.exitCode == null) child.kill('SIGTERM');++ const t = setTimeout(() => { if (child.exitCode == null) child.kill('SIGKILL'); }, 8000);++ await exited;++ clearTimeout(t);++ },++ };++ },++};
@@ -4,7 +4,9 @@ on:
@@ -4,7 +4,9 @@ on:
branches: [main]
branches: [main]
pull_request:
pull_request:
jobs:
jobs:
- # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
+ # install, node --check, npm test (with test/n-1.test.js: the previous release's pages, forms and SDK
+ # calls, and its SQL, against this one, from test/fixtures/n-1) and openvibe-contracts-check: the shared
+ # test job (Track Q).
test:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
@@ -571,8 +571,17 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
@@ -571,8 +571,17 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
npm install
npm install
npm start # http://127.0.0.1:4200
npm start # http://127.0.0.1:4200
npm test # mocks Live, Network and Media in-process; no network needed
npm test # mocks Live, Network and Media in-process; no network needed
+npm run n-1:record # after a deploy: the N-1 fixtures from the deployed commit (or pass <ref>)
```
```
+`test/n-1.test.js` (roadmap WS-P task 11, in `npm test`) runs the previous release's clients against
+this one and its SQL against this schema, from `test/fixtures/n-1/`: every call `community.js`, the
+templates' forms and the openvibe-sdk community client make, and every link, script and form of the
+pages that release served, each answered compatibly (status, JSON, the response fields the client
+reads); then every statement the previous release runs must still prepare after this release opened
+(and migrated) a database the previous one created. After each deploy, record the release now in
+production as the next N-1 and commit `test/fixtures/n-1/`.
+
## Deploy
## Deploy
```
```
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/run.js"
+ "test": "node test/run.js",
+ "n-1:record": "node scripts/n-1-record.js"
},
},
"keywords": [
"keywords": [
"openvibe",
"openvibe",
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Records the N-1 fixtures (roadmap WS-P task 11) from a release: what its client calls and reads,
+ * and what SQL it runs on which schema. test/n-1.test.js replays them against this checkout.
+ *
+ * npm run n-1:record # from HEAD: run it right after a deploy, from the deployed commit,
+ * # so the fixture is N-1 for the next release
+ * npm run n-1:record -- <ref> # from another commit (e.g. the sha in production's /release.json)
+ *
+ * The release is checked out into a temporary git worktree (sharing node_modules), seeded and booted
+ * the way test/n-1/service.js says, and every call its client code makes (test/n-1/harness.js finds
+ * them) is replayed against it; a service with sockets also replays the messages its clients send.
+ * Writes test/fixtures/n-1/client.json and worker.json; commit them.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const Database = require('better-sqlite3');
+const h = require('../test/n-1/harness');
+const svc = require('../test/n-1/service');
+
+const ROOT = path.join(__dirname, '..');
+const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
+
+(async () => {
+ const ref = process.argv[2] || 'HEAD';
+ const wt = h.worktree(ROOT, ref);
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-record-'));
+ const dbPath = path.join(tmp, 'db', `${svc.service}.db`);
+ const dataDir = path.join(tmp, 'data');
+ const sqlOut = path.join(tmp, 'sql.json');
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+ fs.mkdirSync(dataDir, { recursive: true });
+ let server = null;
+ try {
+ console.log(`n-1:record: ${svc.service} at ${wt.sha.slice(0, 12)} (${ref})`);
+
+ // 1. The client: its calls, and the names it reads.
+ const files = svc.clientFiles(wt.dir);
+ const { calls, dynamic } = h.extractCalls({ files, callers: svc.callers, strip: svc.strip, keep: svc.keep, forms: !!svc.forms });
+ const ids = h.clientIdentifiers(files.map((f) => f.text));
+ console.log(` client: ${files.length} files, ${calls.length} call sites with a path (${dynamic} with a computed one, not replayed)`);
+
+ // 2. The release, seeded and booted; every call replayed.
+ await svc.seed({ dir: wt.dir, dbPath, dataDir });
+ server = await svc.boot({ dir: wt.dir, dbPath, dataDir, sqlOut });
+ const manifest = await (await fetch(`${server.url}/release.json`)).json();
+ const probe = await h.notFoundProbe(server.url);
+ const requests = h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false });
+ // Server-rendered pages: an open tab holds the page N-1 served, and follows what it links to.
+ if (svc.crawl) {
+ const have = new Set(requests.map((r) => `${r.method} ${r.path} ${r.auth}`));
+ const push = (r) => { const k = `${r.method} ${r.path} ${r.auth}`; if (!have.has(k)) { have.add(k); requests.push(r); } };
+ for (const page of svc.crawl) {
+ push({ method: 'GET', path: page, auth: 'anon', accept: 'html', from: ['page'] });
+ const got = await h.send(server.url, { method: 'GET', path: h.fillPath(page, server.ids), headers: { accept: 'text/html' } });
+ for (const l of got.html ? h.pageLinks(got.html, svc.origins) : []) {
+ const p = h.unfillPath(l.path, server.ids);
+ if (!svc.keep || svc.keep(p.split('?')[0], l.method, `page ${page}`)) push({ method: l.method, path: p, auth: 'anon', accept: 'html', from: [`page ${page}`] });
+ }
+ }
+ requests.sort((a, b) => (a.method === 'GET' ? 0 : 1) - (b.method === 'GET' ? 0 : 1));
+ }
+ const recorded = [];
+ let unanswered = 0; let noRoute = 0;
+ for (const req of requests) {
+ const got = await h.send(server.url, { method: req.method, path: h.fillPath(req.path, server.ids), headers: { ...(req.accept ? { accept: 'text/html' } : {}), ...server.headers(req.auth) }, body: req.method === 'GET' ? undefined : {} });
+ if (got.status === 0) { unanswered++; continue; }
+ // No route for this path on N-1 itself (a sample value its route does not take, or a dead call):
+ // nothing N has to keep.
+ if (h.isNoRoute(got, probe)) { noRoute++; continue; }
+ const rec = { method: req.method, path: req.path, auth: req.auth, ...(req.accept ? { accept: req.accept } : {}), status: got.status, kind: got.kind };
+ if (got.location) rec.location = got.location;
+ if (got.json != null && got.status >= 200 && got.status < 300) {
+ const reads = h.readsOf(got.json, ids);
+ if (Object.keys(reads).length) rec.reads = reads;
+ }
+ rec.from = req.from;
+ recorded.push(rec);
+ }
+ console.log(` replayed: ${recorded.length} requests (left out: ${unanswered} unanswered, ${noRoute} with no route on N-1)`);
+
+ // Sockets: the messages the client sends, in one session per sign-in state; what comes back.
+ let ws = null;
+ if (svc.ws) {
+ const wsFiles = svc.ws.files ? files.filter((f) => svc.ws.files(f.name)) : files;
+ const sends = h.wsSends(wsFiles, svc.ws.helpers);
+ const handled = h.wsHandled(wsFiles);
+ ws = { path: svc.ws.path, sends, sessions: [] };
+ for (const auth of ['anon', 'user']) {
+ const messages = h.wsMessages(sends, { order: svc.ws.order, values: server.wsValues(auth) });
+ const received = await h.wsSession({ url: server.url.replace(/^http/, 'ws') + svc.ws.path, headers: svc.ws.headers, messages });
+ ws.sessions.push({ auth, received: h.wsReads(received.filter((m) => !(svc.ws.ignore || []).includes(m.type)), handled, ids) });
+ }
+ console.log(` sockets: ${sends.length} message types sent; received, of the types the client handles: ${ws.sessions.map((x) => `${x.auth} ${Object.keys(x.received).length}`).join(', ')}`);
+ }
+ await server.close();
+ server = null;
+
+ // 3. The schema N-1 left, and the SQL it runs on it.
+ const db = new Database(dbPath);
+ const schema = h.schemaDDL(db);
+ const ledger = {};
+ for (const t of svc.ledgerTables || []) {
+ try { ledger[t] = db.prepare(`SELECT * FROM "${t}"`).all(); } catch { /* not there */ }
+ }
+ const userVersion = db.pragma('user_version', { simple: true });
+ const serverFiles = h.readTree(wt.dir, svc.sqlDirs);
+ // What N-1 creates itself on first use or at a full boot: in production it exists before N deploys.
+ const lazy = h.lazyDDL(serverFiles, db);
+ const ran = fs.existsSync(sqlOut) ? JSON.parse(fs.readFileSync(sqlOut, 'utf8')) : [];
+ const candidates = [...new Set([...ran.map(h.normalizeSql).filter((s) => /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i.test(s)), ...h.sqlLiterals(serverFiles)])].sort();
+ const failing = new Set(h.prepareProblems(db, candidates).map((p) => p.sql));
+ const statements = candidates.filter((s) => !failing.has(s));
+ db.close();
+ console.log(` sql: ${statements.length} statements (${ran.length} ran, ${failing.size} candidates that do not prepare on N-1's own schema left out), ${schema.length} schema objects, ${lazy.length} created on first use`);
+
+ fs.mkdirSync(OUT, { recursive: true });
+ const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };
+ const clients = svc.clientReleases ? svc.clientReleases() : null;
+ fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);
+ fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);
+ console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
+ } finally {
+ if (server) await server.close();
+ wt.remove();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,1090 @@
@@ -0,0 +1,1090 @@
+{
+ "service": "community",
+ "release": "19441aa2511a7648f1dd3c1d9f70fe74e41cb530",
+ "recorded_at": "2026-09-26T14:07:23.825Z",
+ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
+ "manifest": {
+ "service": "community",
+ "release": "19441aa2511a",
+ "released_at": "2026-09-26T13:13:46.000Z",
+ "booted_at": "2026-09-26T14:07:22.838Z",
+ "contracts_version": "0.49.0",
+ "packages": {
+ "openvibe-shared": "1.22.0",
+ "openvibe-sdk": "0.11.0"
+ },
+ "min_client_release": null,
+ "mixed_version_window_hours": 24,
+ "components": {
+ "shell": {
+ "kind": "script",
+ "version": "19441aa2511a"
+ },
+ "server": {
+ "kind": "server",
+ "version": "19441aa2511a"
+ }
+ },
+ "assets": {},
+ "schema_generation": null,
+ "schema_compatible_from": null,
+ "contract_ranges": {},
+ "metrics_url": "/release-metrics"
+ },
+ "dynamic_calls": 3,
+ "calls": [
+ {
+ "method": "GET",
+ "path": "/api/pastes",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "pastes": "array",
+ "pastes[]": "object",
+ "pastes[].id": "number",
+ "pastes[].slug": "string",
+ "pastes[].user_id": "string",
+ "pastes[].owner_subject": "string",
+ "pastes[].origin": "string",
+ "pastes[].type": "string",
+ "pastes[].title": "string",
+ "pastes[].content": "string",
+ "pastes[].language": "string",
+ "pastes[].visibility": "string",
+ "pastes[].screenshot_url": "null",
+ "pastes[].burn_after_read": "boolean",
+ "pastes[].pinned": "boolean",
+ "pastes[].views": "number",
+ "pastes[].is_nsfw": "boolean",
+ "pastes[].ai_summary": "null",
+ "pastes[].revision": "number",
+ "pastes[].url": "string",
+ "pastes[].created_at": "string",
+ "pastes[].updated_at": "string",
+ "pastes[].is_owner": "boolean",
+ "pastes[].username": "string",
+ "pastes[].display_name": "string",
+ "pastes[].avatar_url": "null",
+ "total": "number",
+ "limit": "number",
+ "offset": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "pastes": "array",
+ "pastes[]": "object",
+ "pastes[].id": "number",
+ "pastes[].slug": "string",
+ "pastes[].user_id": "string",
+ "pastes[].owner_subject": "string",
+ "pastes[].origin": "string",
+ "pastes[].type": "string",
+ "pastes[].title": "string",
+ "pastes[].content": "string",
+ "pastes[].language": "string",
+ "pastes[].visibility": "string",
+ "pastes[].screenshot_url": "null",
+ "pastes[].burn_after_read": "boolean",
+ "pastes[].pinned": "boolean",
+ "pastes[].views": "number",
+ "pastes[].is_nsfw": "boolean",
+ "pastes[].ai_summary": "null",
+ "pastes[].revision": "number",
+ "pastes[].url": "string",
+ "pastes[].created_at": "string",
+ "pastes[].updated_at": "string",
+ "pastes[].is_owner": "boolean",
+ "pastes[].username": "string",
+ "pastes[].display_name": "string",
+ "pastes[].avatar_url": "null",
+ "total": "number",
+ "limit": "number",
+ "offset": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/by-user/n1star",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "pastes": "array",
+ "pastes[]": "object",
+ "pastes[].id": "number",
+ "pastes[].slug": "string",
+ "pastes[].user_id": "string",
+ "pastes[].owner_subject": "string",
+ "pastes[].origin": "string",
+ "pastes[].type": "string",
+ "pastes[].title": "string",
+ "pastes[].content": "string",
+ "pastes[].language": "string",
+ "pastes[].visibility": "string",
+ "pastes[].screenshot_url": "null",
+ "pastes[].burn_after_read": "boolean",
+ "pastes[].pinned": "boolean",
+ "pastes[].views": "number",
+ "pastes[].is_nsfw": "boolean",
+ "pastes[].ai_summary": "null",
+ "pastes[].revision": "number",
+ "pastes[].url": "string",
+ "pastes[].created_at": "string",
+ "pastes[].updated_at": "string",
+ "pastes[].is_owner": "boolean",
+ "pastes[].username": "string",
+ "pastes[].display_name": "string",
+ "pastes[].avatar_url": "null",
+ "total": "number",
+ "username": "string"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/by-user/n1star",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "pastes": "array",
+ "pastes[]": "object",
+ "pastes[].id": "number",
+ "pastes[].slug": "string",
+ "pastes[].user_id": "string",
+ "pastes[].owner_subject": "string",
+ "pastes[].origin": "string",
+ "pastes[].type": "string",
+ "pastes[].title": "string",
+ "pastes[].content": "string",
+ "pastes[].language": "string",
+ "pastes[].visibility": "string",
+ "pastes[].screenshot_url": "null",
+ "pastes[].burn_after_read": "boolean",
+ "pastes[].pinned": "boolean",
+ "pastes[].views": "number",
+ "pastes[].is_nsfw": "boolean",
+ "pastes[].ai_summary": "null",
+ "pastes[].revision": "number",
+ "pastes[].url": "string",
+ "pastes[].created_at": "string",
+ "pastes[].updated_at": "string",
+ "pastes[].is_owner": "boolean",
+ "pastes[].username": "string",
+ "pastes[].display_name": "string",
+ "pastes[].avatar_url": "null",
+ "total": "number",
+ "username": "string"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/config",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/config",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/{@paste}",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "paste": "object",
+ "paste.id": "number",
+ "paste.slug": "string",
+ "paste.user_id": "string",
+ "paste.owner_subject": "string",
+ "paste.origin": "string",
+ "paste.type": "string",
+ "paste.title": "string",
+ "paste.content": "string",
+ "paste.language": "string",
+ "paste.visibility": "string",
+ "paste.screenshot_url": "null",
+ "paste.burn_after_read": "boolean",
+ "paste.pinned": "boolean",
+ "paste.views": "number",
+ "paste.is_nsfw": "boolean",
+ "paste.ai_summary": "null",
+ "paste.revision": "number",
+ "paste.url": "string",
+ "paste.created_at": "string",
+ "paste.updated_at": "string",
+ "paste.is_owner": "boolean",
+ "paste.username": "string",
+ "paste.display_name": "string",
+ "paste.avatar_url": "null"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/{@paste}",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "paste": "object",
+ "paste.id": "number",
+ "paste.slug": "string",
+ "paste.user_id": "string",
+ "paste.owner_subject": "string",
+ "paste.origin": "string",
+ "paste.type": "string",
+ "paste.title": "string",
+ "paste.content": "string",
+ "paste.language": "string",
+ "paste.visibility": "string",
+ "paste.screenshot_url": "null",
+ "paste.burn_after_read": "boolean",
+ "paste.pinned": "boolean",
+ "paste.views": "number",
+ "paste.is_nsfw": "boolean",
+ "paste.ai_summary": "null",
+ "paste.revision": "number",
+ "paste.url": "string",
+ "paste.created_at": "string",
+ "paste.updated_at": "string",
+ "paste.is_owner": "boolean",
+ "paste.username": "string",
+ "paste.display_name": "string",
+ "paste.avatar_url": "null"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/{@paste}/comments",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "comments": "array",
+ "total": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/{@paste}/comments",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "comments": "array",
+ "total": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/{@paste}/versions",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/pastes/{@paste}/versions",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "revision": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/pastes",
+ "auth": "anon",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "server/render/pages.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/pastes",
+ "auth": "user",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "server/render/pages.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/search",
+ "auth": "anon",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "server/render/search.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/search",
+ "auth": "user",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "server/render/search.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/assets/logo-192.png",
+ "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",
+ "display": "string"
+ },
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/feed.xml",
+ "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": "/css/community.css?v=3bf26cdbb2",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/navbar.js?v=b50d9f47fc34",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/footer.js?v=b99e512398d5",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/js/community.js?v=d3f570525d",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/s",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/new",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/auth/login?next=%2F",
+ "auth": "anon",
+ "accept": "html",
+ "status": 302,
+ "kind": "html",
+ "location": "/oauth/authorize",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/p/{@paste}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/pastes?lang=javascript",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/pastes?sort=views",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/s/general",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/pulse",
+ "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": "/sitemap.xml",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/p/{@paste}/raw",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /p/{@paste}"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/p/{@paste}/download",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /p/{@paste}"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/new?fork={@paste}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /p/{@paste}"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/s/discuss?paste={@paste}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /p/{@paste}"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/s/general/feed.xml",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /s/general"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/s/general/new",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /s/general"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/s/general/t/{@thread_slug}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /s/general"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/s/general/t/n-1-thread",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/auth/login?next=%2Fs%2Fgeneral%2Ft%2F{@thread_slug}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 302,
+ "kind": "html",
+ "location": "/oauth/authorize",
+ "from": [
+ "page /s/general/t/n-1-thread"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/c/{@comment_access}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/c/{@comment_thread}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /c/{@comment_access}"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/auth/login?next=%2Fc%2F{@comment_thread}",
+ "auth": "anon",
+ "accept": "html",
+ "status": 302,
+ "kind": "html",
+ "location": "/oauth/authorize",
+ "from": [
+ "page /c/{@comment_access}"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/s/feed.xml",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /pulse"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/pulse?origin=user",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /pulse"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/pulse?origin=ai",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /pulse"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/pulse?origin=system",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /pulse"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/auth/login?next=%2Fnew",
+ "auth": "anon",
+ "accept": "html",
+ "status": 302,
+ "kind": "html",
+ "location": "/oauth/authorize",
+ "from": [
+ "page /new"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/my",
+ "auth": "anon",
+ "accept": "html",
+ "status": 302,
+ "kind": "html",
+ "location": "/auth/login",
+ "from": [
+ "page"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/pastes",
+ "auth": "anon",
+ "status": 400,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js",
+ "public/js/community.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/pastes/screenshot",
+ "auth": "anon",
+ "status": 400,
+ "kind": "json",
+ "from": [
+ "public/js/community.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/pastes/{@paste}",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/api/pastes/{@paste}",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/pastes/{@paste}/comments",
+ "auth": "anon",
+ "status": 400,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/pastes/{@paste}/comments/1",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/pastes/{@paste}/copy",
+ "auth": "anon",
+ "status": 200,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/pastes/{@paste}/fork",
+ "auth": "anon",
+ "status": 201,
+ "kind": "json",
+ "reads": {
+ "id": "number",
+ "slug": "string",
+ "url": "string",
+ "paste": "object",
+ "paste.id": "number",
+ "paste.slug": "string",
+ "paste.user_id": "null",
+ "paste.owner_subject": "null",
+ "paste.origin": "string",
+ "paste.type": "string",
+ "paste.title": "string",
+ "paste.content": "string",
+ "paste.language": "string",
+ "paste.visibility": "string",
+ "paste.screenshot_url": "null",
+ "paste.burn_after_read": "boolean",
+ "paste.pinned": "boolean",
+ "paste.views": "number",
+ "paste.is_nsfw": "boolean",
+ "paste.ai_summary": "null",
+ "paste.revision": "number",
+ "paste.url": "string",
+ "paste.created_at": "string",
+ "paste.updated_at": "string",
+ "paste.is_owner": "boolean",
+ "paste.username": "null",
+ "paste.display_name": "null",
+ "paste.avatar_url": "null"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/pastes/{@paste}/like",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/community.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/c/{@comment_access}",
+ "auth": "anon",
+ "status": 303,
+ "kind": "empty",
+ "location": "/auth/login",
+ "from": [
+ "server/render/comments.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/new",
+ "auth": "anon",
+ "status": 400,
+ "kind": "html",
+ "from": [
+ "server/render/pages.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/new-space",
+ "auth": "anon",
+ "status": 403,
+ "kind": "html",
+ "from": [
+ "server/render/board.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/chat-room",
+ "auth": "anon",
+ "status": 303,
+ "kind": "empty",
+ "location": "/auth/login",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/chat-room/detach",
+ "auth": "anon",
+ "status": 303,
+ "kind": "empty",
+ "location": "/auth/login",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/new",
+ "auth": "anon",
+ "status": 303,
+ "kind": "empty",
+ "location": "/auth/login",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/settings",
+ "auth": "anon",
+ "status": 403,
+ "kind": "html",
+ "from": [
+ "server/render/board.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/t/n-1-thread/category",
+ "auth": "anon",
+ "status": 403,
+ "kind": "html",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/t/n-1-thread/crosspost",
+ "auth": "anon",
+ "status": 303,
+ "kind": "empty",
+ "location": "/auth/login",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/t/n-1-thread/delete",
+ "auth": "anon",
+ "status": 403,
+ "kind": "html",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/t/n-1-thread/members-only",
+ "auth": "anon",
+ "status": 403,
+ "kind": "html",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/t/n-1-thread/react",
+ "auth": "anon",
+ "status": 303,
+ "kind": "empty",
+ "location": "/auth/login",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/t/n-1-thread/reply",
+ "auth": "anon",
+ "status": 303,
+ "kind": "empty",
+ "location": "/auth/login",
+ "from": [
+ "server/render/board.js",
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/t/n-1-thread/state",
+ "auth": "anon",
+ "status": 403,
+ "kind": "html",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/t/n-1-thread/status",
+ "auth": "anon",
+ "status": 403,
+ "kind": "html",
+ "from": [
+ "server/render/forum.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/s/general/t/n-1-thread/vote",
+ "auth": "anon",
+ "status": 400,
+ "kind": "html",
+ "from": [
+ "server/render/forum.js"
+ ]
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,245 @@
@@ -0,0 +1,245 @@
+{
+ "service": "community",
+ "release": "19441aa2511a7648f1dd3c1d9f70fe74e41cb530",
+ "recorded_at": "2026-09-26T14:07:23.825Z",
+ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
+ "user_version": 0,
+ "schema": [
+ "CREATE TABLE attachments (\n media_id TEXT PRIMARY KEY,\n owner_subject TEXT NOT NULL,\n post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,\n position INTEGER NOT NULL DEFAULT 0,\n filename TEXT,\n mime TEXT NOT NULL,\n size_bytes INTEGER NOT NULL,\n url TEXT NOT NULL,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE categories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,\n slug TEXT NOT NULL,\n name TEXT NOT NULL,\n description TEXT,\n position INTEGER NOT NULL DEFAULT 0,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n UNIQUE (space_id, slug)\n)",
+ "CREATE TABLE comment_threads (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ref_service TEXT NOT NULL,\n ref_type TEXT NOT NULL,\n ref_id TEXT NOT NULL,\n ref_label TEXT, -- cached display label from a service; never authoritative\n visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')),\n comment_count INTEGER NOT NULL DEFAULT 0,\n created_by TEXT, -- subject or service principal that first resolved it\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n access_id TEXT, -- unguessable handle for browsers (migrate() fills old rows, unique index)\n UNIQUE (ref_service, ref_type, ref_id)\n)",
+ "CREATE TABLE comment_votes (\n comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n value INTEGER NOT NULL CHECK(value IN (-1, 1)),\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (comment_id, subject_id)\n)",
+ "CREATE TABLE comments (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n thread_id INTEGER NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,\n parent_id INTEGER REFERENCES comments(id) ON DELETE CASCADE,\n author_subject TEXT,\n anon_name TEXT,\n origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai')),\n message TEXT NOT NULL,\n score INTEGER NOT NULL DEFAULT 0,\n upvotes INTEGER NOT NULL DEFAULT 0,\n downvotes INTEGER NOT NULL DEFAULT 0,\n reply_count INTEGER NOT NULL DEFAULT 0,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME,\n deleted_by TEXT,\n edited_at DATETIME -- the author last changed the text (migrate() adds it to older databases)\n)",
+ "CREATE TABLE community_event_inbox (\n consumer TEXT NOT NULL,\n event_id TEXT NOT NULL,\n processed_at INTEGER NOT NULL,\n PRIMARY KEY (consumer, event_id)\n )",
+ "CREATE TABLE import_hold (\n source_type TEXT NOT NULL,\n source_id TEXT NOT NULL,\n reason TEXT NOT NULL,\n detail TEXT,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (source_type, source_id, reason)\n)",
+ "CREATE TABLE legacy_id_map (\n source_system TEXT NOT NULL,\n source_type TEXT NOT NULL,\n source_id TEXT NOT NULL,\n target_type TEXT NOT NULL,\n target_id TEXT NOT NULL,\n PRIMARY KEY (source_system, source_type, source_id)\n)",
+ "CREATE TABLE migration_runs (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source TEXT NOT NULL,\n started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n finished_at DATETIME,\n report TEXT\n)",
+ "CREATE TABLE paste_comments (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n author_subject TEXT,\n anon_name TEXT,\n parent_id INTEGER REFERENCES paste_comments(id) ON DELETE CASCADE,\n message TEXT NOT NULL,\n is_deleted INTEGER NOT NULL DEFAULT 0,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n legacy_media_id INTEGER UNIQUE\n)",
+ "CREATE TABLE paste_likes (\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (paste_id, subject_id)\n)",
+ "CREATE TABLE paste_versions (\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n revision INTEGER NOT NULL,\n title TEXT,\n content TEXT,\n language TEXT,\n edited_by TEXT,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (paste_id, revision)\n)",
+ "CREATE TABLE paste_visits (\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n visitor TEXT NOT NULL,\n first_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n last_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n visits INTEGER NOT NULL DEFAULT 1,\n PRIMARY KEY (paste_id, visitor)\n)",
+ "CREATE TABLE pastes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT UNIQUE NOT NULL,\n owner_subject TEXT,\n origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'imported')),\n type TEXT NOT NULL DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),\n title TEXT NOT NULL DEFAULT 'Untitled',\n content TEXT,\n language TEXT DEFAULT 'text',\n visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),\n screenshot_url TEXT,\n media_ref TEXT,\n stream_ref TEXT, -- JSON EntityRef, e.g. {\"service\":\"live\",\"type\":\"stream\",\"id\":\"123\"}\n metadata TEXT, -- JSON\n burn_after_read INTEGER NOT NULL DEFAULT 0,\n forked_from INTEGER REFERENCES pastes(id) ON DELETE SET NULL,\n pinned INTEGER NOT NULL DEFAULT 0,\n views INTEGER NOT NULL DEFAULT 0,\n unique_views INTEGER NOT NULL DEFAULT 0,\n copies INTEGER NOT NULL DEFAULT 0,\n likes INTEGER NOT NULL DEFAULT 0,\n is_nsfw INTEGER NOT NULL DEFAULT 0,\n ai_summary TEXT,\n ai_tags TEXT, -- JSON array\n ai_analyzed_at DATETIME,\n revision INTEGER NOT NULL DEFAULT 1,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME,\n legacy_media_id INTEGER UNIQUE,\n legacy_user_id INTEGER\n)",
+ "CREATE TABLE post_pastes (\n post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,\n paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,\n position INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (post_id, paste_id)\n)",
+ "CREATE TABLE post_reactions (\n post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n reaction TEXT NOT NULL,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (post_id, subject_id)\n)",
+ "CREATE TABLE post_versions (\n post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,\n revision INTEGER NOT NULL,\n body_markdown TEXT NOT NULL,\n edited_by TEXT,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (post_id, revision)\n)",
+ "CREATE TABLE posts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,\n author_subject TEXT,\n origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),\n is_opening INTEGER NOT NULL DEFAULT 0,\n body_markdown TEXT NOT NULL,\n revision INTEGER NOT NULL DEFAULT 1,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME\n, relay_author TEXT)",
+ "CREATE TABLE pulse_items (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n source_service TEXT NOT NULL,\n source_type TEXT NOT NULL,\n source_id TEXT NOT NULL,\n title TEXT NOT NULL,\n url TEXT NOT NULL,\n actor_subject TEXT,\n origin TEXT NOT NULL CHECK(origin IN ('user', 'ai', 'system')),\n visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public')),\n occurred_at DATETIME NOT NULL,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n UNIQUE (source_service, source_type, source_id)\n)",
+ "CREATE TABLE relay_cursors (\n name TEXT PRIMARY KEY,\n cursor INTEGER NOT NULL,\n latest_seq INTEGER,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE relay_deliveries (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,\n post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,\n mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,\n action TEXT NOT NULL DEFAULT 'create' CHECK(action IN ('create', 'edit', 'delete')),\n dedupe_key TEXT UNIQUE NOT NULL,\n source TEXT NOT NULL DEFAULT 'direct' CHECK(source IN ('direct', 'events')),\n event_id TEXT,\n status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed', 'dropped', 'skipped')),\n attempts INTEGER NOT NULL DEFAULT 0,\n next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n last_status INTEGER,\n last_error TEXT,\n delivered_at DATETIME,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE relay_inbound_failures (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n mapping_id INTEGER REFERENCES relay_mappings(id) ON DELETE SET NULL,\n event TEXT NOT NULL,\n external_channel_id TEXT,\n external_message_id TEXT,\n external_author_id TEXT,\n error TEXT NOT NULL,\n dismissed_at DATETIME,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE relay_mappings (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,\n direction TEXT NOT NULL DEFAULT 'out' CHECK(direction IN ('out')),\n webhook_url_ref TEXT NOT NULL,\n enabled INTEGER NOT NULL DEFAULT 1,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, discord_channel_id TEXT, discord_thread_id TEXT, inbound INTEGER NOT NULL DEFAULT 0,\n UNIQUE (space_id, direction, webhook_url_ref)\n)",
+ "CREATE TABLE relay_message_map (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n platform TEXT NOT NULL DEFAULT 'discord' CHECK(platform IN ('discord')),\n mapping_id INTEGER NOT NULL REFERENCES relay_mappings(id) ON DELETE CASCADE,\n direction TEXT NOT NULL CHECK(direction IN ('out', 'in')),\n local_type TEXT NOT NULL CHECK(local_type IN ('thread', 'post')),\n local_id INTEGER NOT NULL,\n thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,\n external_channel_id TEXT NOT NULL,\n external_thread_id TEXT,\n external_message_id TEXT NOT NULL,\n external_webhook_id TEXT,\n external_deleted_at DATETIME,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n UNIQUE (platform, external_message_id),\n UNIQUE (platform, mapping_id, local_type, local_id)\n)",
+ "CREATE TABLE space_chat_rooms (\n space_id INTEGER PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,\n room_id INTEGER,\n room_slug TEXT NOT NULL,\n room_name TEXT NOT NULL,\n room_kind TEXT NOT NULL DEFAULT 'community',\n room_visibility TEXT NOT NULL DEFAULT 'public',\n attached_by TEXT,\n attached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE space_groups (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT UNIQUE NOT NULL,\n name TEXT NOT NULL,\n description TEXT,\n position INTEGER NOT NULL DEFAULT 0\n)",
+ "CREATE TABLE spaces (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n slug TEXT UNIQUE NOT NULL,\n name TEXT NOT NULL,\n description TEXT,\n visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'members', 'staff')),\n created_by TEXT,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n, members_only_owner TEXT, thread_kind TEXT NOT NULL DEFAULT 'discussion', style TEXT NOT NULL DEFAULT 'feed', votes INTEGER NOT NULL DEFAULT 1, reactions INTEGER NOT NULL DEFAULT 1, group_id INTEGER REFERENCES space_groups(id) ON DELETE SET NULL, parent_id INTEGER REFERENCES spaces(id) ON DELETE SET NULL, position INTEGER NOT NULL DEFAULT 0)",
+ "CREATE TABLE subject_projection (\n subject_id TEXT PRIMARY KEY,\n username TEXT,\n display_name TEXT,\n avatar_url TEXT,\n profile_color TEXT,\n refreshed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE thread_votes (\n thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,\n subject_id TEXT NOT NULL,\n value INTEGER NOT NULL CHECK(value IN (-1, 1)),\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (thread_id, subject_id)\n)",
+ "CREATE TABLE threads (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,\n slug TEXT NOT NULL,\n title TEXT NOT NULL,\n author_subject TEXT,\n origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'discord', 'system')),\n pinned INTEGER NOT NULL DEFAULT 0,\n locked INTEGER NOT NULL DEFAULT 0,\n score INTEGER NOT NULL DEFAULT 0,\n reply_count INTEGER NOT NULL DEFAULT 0,\n last_activity_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME, members_only_owner TEXT, kind TEXT NOT NULL DEFAULT 'discussion', status TEXT, category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, external_key TEXT, views INTEGER NOT NULL DEFAULT 0, crosspost_of INTEGER REFERENCES threads(id) ON DELETE SET NULL,\n UNIQUE (space_id, slug)\n)",
+ "CREATE TABLE token_revocations (\n subject_id TEXT PRIMARY KEY,\n valid_after_ms INTEGER NOT NULL,\n reason TEXT,\n updated_at INTEGER NOT NULL\n )",
+ "CREATE INDEX idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL",
+ "CREATE UNIQUE INDEX idx_comment_threads_access ON comment_threads(access_id)",
+ "CREATE INDEX idx_comments_parent ON comments(parent_id, id)",
+ "CREATE INDEX idx_comments_thread ON comments(thread_id, parent_id, id)",
+ "CREATE INDEX idx_paste_comments_paste ON paste_comments(paste_id, parent_id)",
+ "CREATE INDEX idx_paste_visits_last ON paste_visits(last_at)",
+ "CREATE INDEX idx_pastes_forked ON pastes(forked_from)",
+ "CREATE INDEX idx_pastes_listing ON pastes(visibility, created_at DESC) WHERE deleted_at IS NULL",
+ "CREATE INDEX idx_pastes_owner ON pastes(owner_subject, created_at DESC)",
+ "CREATE INDEX idx_post_reactions_post ON post_reactions(post_id, reaction)",
+ "CREATE INDEX idx_posts_author ON posts(author_subject, created_at DESC)",
+ "CREATE INDEX idx_posts_thread ON posts(thread_id, id)",
+ "CREATE INDEX idx_pulse_origin ON pulse_items(origin, occurred_at DESC, id DESC)",
+ "CREATE INDEX idx_pulse_recent ON pulse_items(occurred_at DESC, id DESC)",
+ "CREATE INDEX idx_relay_deliveries_due ON relay_deliveries(status, next_attempt_at)",
+ "CREATE INDEX idx_relay_deliveries_thread ON relay_deliveries(thread_id, post_id)",
+ "CREATE INDEX idx_relay_mappings_channel ON relay_mappings(discord_channel_id) WHERE discord_channel_id IS NOT NULL",
+ "CREATE INDEX idx_relay_message_map_thread ON relay_message_map(thread_id)",
+ "CREATE INDEX idx_subject_projection_username ON subject_projection(username COLLATE NOCASE)",
+ "CREATE INDEX idx_threads_author ON threads(author_subject, created_at DESC)",
+ "CREATE INDEX idx_threads_category ON threads(category_id) WHERE category_id IS NOT NULL",
+ "CREATE UNIQUE INDEX idx_threads_external_key ON threads(space_id, external_key) WHERE external_key IS NOT NULL",
+ "CREATE INDEX idx_threads_space_new ON threads(space_id, created_at DESC) WHERE deleted_at IS NULL"
+ ],
+ "ledger": {},
+ "lazy": [
+ "CREATE TABLE IF NOT EXISTS network_blocks ( blocker_subject TEXT NOT NULL, blocked_subject TEXT NOT NULL, active INTEGER NOT NULL, revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (blocker_subject, blocked_subject) )",
+ "CREATE TABLE IF NOT EXISTS profile_module_pushes ( subject_id TEXT PRIMARY KEY, hash TEXT NOT NULL, pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP )"
+ ],
+ "statements": [
+ "DELETE FROM categories WHERE id = ?",
+ "DELETE FROM import_hold WHERE source_type = ? AND source_id = ?",
+ "DELETE FROM import_hold WHERE source_type = ? AND source_id = ? AND reason IN ('ambiguous_owner', 'owner_unmapped')",
+ "DELETE FROM post_reactions WHERE post_id = ? AND subject_id = ?",
+ "DELETE FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?",
+ "DELETE FROM relay_inbound_failures WHERE id <= (SELECT MAX(id) FROM relay_inbound_failures) - ?",
+ "DELETE FROM space_chat_rooms WHERE space_id = ?",
+ "INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?) ON CONFLICT (space_id, slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position",
+ "INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by, access_id) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING",
+ "INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message, created_at, updated_at, edited_at) VALUES (?, ?, ?, NULL, 'user', ?, ?, ?, ?)",
+ "INSERT INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?) ON CONFLICT(source_type, source_id, reason) DO UPDATE SET detail = excluded.detail",
+ "INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, 'comment', ?) ON CONFLICT(source_system, source_type, source_id) DO NOTHING",
+ "INSERT INTO migration_runs (source, finished_at, report) VALUES (?, CURRENT_TIMESTAMP, ?)",
+ "INSERT INTO migration_runs (source, started_at, finished_at, report) VALUES (?, ?, ?, ?)",
+ "INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(blocker_subject, blocked_subject) DO UPDATE SET active = excluded.active, revision = excluded.revision, updated_at = excluded.updated_at WHERE excluded.revision > network_blocks.revision",
+ "INSERT INTO paste_comments (paste_id, author_subject, anon_name, message, is_deleted, created_at, updated_at, legacy_media_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO paste_visits (paste_id, visitor) VALUES (?, ?)",
+ "INSERT INTO pastes (slug, owner_subject, origin, type, title, content, language, visibility, forked_from) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO pastes (slug, owner_subject, origin, type, title, content, language, visibility, stream_ref, metadata, burn_after_read, is_nsfw) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (?, ?, ?) ON CONFLICT (post_id, subject_id) DO UPDATE SET reaction = excluded.reaction, created_at = CURRENT_TIMESTAMP",
+ "INSERT INTO post_versions (post_id, revision, body_markdown, edited_by) VALUES (?, ?, ?, ?)",
+ "INSERT INTO post_versions (post_id, revision, body_markdown, edited_by, created_at) VALUES (?, ?, ?, ?, ?)",
+ "INSERT INTO posts (thread_id, author_subject, origin, body_markdown, relay_author) VALUES (?, ?, ?, ?, ?)",
+ "INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)",
+ "INSERT INTO profile_module_pushes (subject_id, hash, pushed_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(subject_id) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at",
+ "INSERT INTO pulse_items (source_service, source_type, source_id, title, url, actor_subject, origin, visibility, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'public', ?)",
+ "INSERT INTO relay_cursors (name, cursor, latest_seq, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(name) DO UPDATE SET cursor = excluded.cursor, latest_seq = excluded.latest_seq, updated_at = CURRENT_TIMESTAMP",
+ "INSERT INTO relay_deliveries (thread_id, post_id, mapping_id, action, dedupe_key, source, event_id, next_attempt_at) VALUES (@thread_id, @post_id, @mapping_id, @action, @key, @source, @event_id, @at) ON CONFLICT(dedupe_key) DO NOTHING",
+ "INSERT INTO relay_inbound_failures (mapping_id, event, external_channel_id, external_message_id, external_author_id, error) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO relay_message_map (platform, mapping_id, direction, local_type, local_id, thread_id, external_channel_id, external_message_id) VALUES ('discord', ?, 'in', 'post', ?, ?, ?, ?)",
+ "INSERT INTO space_chat_rooms (space_id, room_id, room_slug, room_name, room_kind, room_visibility, attached_by) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (space_id) DO UPDATE SET room_id = excluded.room_id, room_slug = excluded.room_slug, room_name = excluded.room_name, room_kind = excluded.room_kind, room_visibility = excluded.room_visibility, attached_by = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_by ELSE excluded.attached_by END, attached_at = CASE WHEN space_chat_rooms.room_slug = excluded.room_slug THEN space_chat_rooms.attached_at ELSE CURRENT_TIMESTAMP END",
+ "INSERT INTO space_groups (slug, name, description, position) VALUES (?, ?, ?, ?) ON CONFLICT (slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position",
+ "INSERT INTO spaces (slug, name, description, visibility, created_by, style, votes, reactions, group_id, parent_id, position, thread_kind) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at) VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, CURRENT_TIMESTAMP) ON CONFLICT(subject_id) DO UPDATE SET username = COALESCE(excluded.username, username), display_name = COALESCE(excluded.display_name, display_name), avatar_url = COALESCE(excluded.avatar_url, avatar_url), profile_color = COALESCE(excluded.profile_color, profile_color), refreshed_at = CURRENT_TIMESTAMP",
+ "INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key, crosspost_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO token_revocations (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at WHERE excluded.valid_after_ms > token_revocations.valid_after_ms",
+ "INSERT OR IGNORE INTO categories (space_id, slug, name, description, position) SELECT id, ?, ?, ?, ? FROM spaces WHERE slug = ?",
+ "INSERT OR IGNORE INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)",
+ "INSERT OR IGNORE INTO paste_likes (paste_id, subject_id, created_at) VALUES (?, ?, ?)",
+ "INSERT OR IGNORE INTO post_pastes (post_id, paste_id, position) VALUES (?, ?, ?)",
+ "SELECT * FROM attachments WHERE media_id = ?",
+ "SELECT * FROM attachments WHERE post_id IN (?) ORDER BY post_id, position",
+ "SELECT * FROM categories WHERE id = ?",
+ "SELECT * FROM categories WHERE space_id = ? AND slug = ?",
+ "SELECT * FROM comment_threads WHERE access_id = ?",
+ "SELECT * FROM comment_threads WHERE id = ?",
+ "SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?",
+ "SELECT * FROM comments WHERE id = ?",
+ "SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?",
+ "SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND (deleted_at IS NULL OR reply_count > 0) ORDER BY id DESC LIMIT ?",
+ "SELECT * FROM paste_comments WHERE legacy_media_id = ?",
+ "SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC",
+ "SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
+ "SELECT * FROM pastes",
+ "SELECT * FROM pastes WHERE (updated_at >= @from AND updated_at < @to) OR (deleted_at >= @from AND deleted_at < @to) OR (created_at >= @from AND created_at < @to)",
+ "SELECT * FROM pastes WHERE deleted_at IS NULL AND owner_subject = ? ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ? OFFSET ?",
+ "SELECT * FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 AND owner_subject = ? ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ? OFFSET ?",
+ "SELECT * FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 AND type = ? ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ? OFFSET ?",
+ "SELECT * FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ? OFFSET ?",
+ "SELECT * FROM pastes WHERE id = ? AND deleted_at IS NULL",
+ "SELECT * FROM pastes WHERE legacy_media_id = ?",
+ "SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL",
+ "SELECT * FROM posts WHERE id = ?",
+ "SELECT * FROM posts WHERE thread_id = ? ORDER BY id ASC LIMIT ? OFFSET ?",
+ "SELECT * FROM pulse_items WHERE source_service = ? AND source_type = ? AND source_id = ?",
+ "SELECT * FROM relay_mappings WHERE id = ?",
+ "SELECT * FROM relay_message_map WHERE platform = 'discord' AND external_message_id = ?",
+ "SELECT * FROM space_chat_rooms WHERE space_id = ?",
+ "SELECT * FROM space_groups ORDER BY position, name COLLATE NOCASE",
+ "SELECT * FROM spaces WHERE id = ?",
+ "SELECT * FROM spaces WHERE slug = ?",
+ "SELECT * FROM subject_projection WHERE subject_id = ?",
+ "SELECT * FROM threads WHERE id = ?",
+ "SELECT * FROM threads WHERE id = ? AND deleted_at IS NULL",
+ "SELECT * FROM threads WHERE space_id = ? AND external_key = ?",
+ "SELECT * FROM threads WHERE space_id = ? AND slug = ? AND deleted_at IS NULL",
+ "SELECT 1 FROM network_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1",
+ "SELECT 1 FROM paste_likes WHERE paste_id = ? AND subject_id = ?",
+ "SELECT 1 FROM pastes WHERE slug = ?",
+ "SELECT 1 FROM post_versions WHERE post_id = ? AND revision = ?",
+ "SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id WHERE p.id = ? AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'",
+ "SELECT 1 FROM threads WHERE space_id = ? AND slug = ?",
+ "SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0",
+ "SELECT COUNT(*) AS c FROM pastes WHERE owner_subject = ? AND created_at > datetime('now', ?)",
+ "SELECT COUNT(*) AS c FROM posts WHERE author_subject = ? AND created_at > datetime('now', ?)",
+ "SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?",
+ "SELECT COUNT(*) AS c FROM threads WHERE author_subject = ? AND created_at > datetime('now', ?)",
+ "SELECT COUNT(*) AS n FROM relay_inbound_failures WHERE dismissed_at IS NULL",
+ "SELECT COUNT(*) AS n FROM relay_message_map WHERE external_deleted_at IS NULL",
+ "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'",
+ "SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM comments WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'",
+ "SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM pastes WHERE owner_subject = ? AND deleted_at IS NULL AND origin = 'user' AND visibility = 'public'",
+ "SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM posts WHERE author_subject = ? AND deleted_at IS NULL AND is_opening = 0 AND origin = 'user'",
+ "SELECT COUNT(*) AS n, MIN(created_at) AS first, MAX(created_at) AS last FROM threads WHERE author_subject = ? AND deleted_at IS NULL AND origin = 'user'",
+ "SELECT COUNT(*) AS total FROM pastes WHERE deleted_at IS NULL AND owner_subject = ?",
+ "SELECT COUNT(*) AS total FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0",
+ "SELECT COUNT(*) AS total FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 AND owner_subject = ?",
+ "SELECT COUNT(*) AS total FROM pastes WHERE deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0 AND type = ?",
+ "SELECT COUNT(*) AS total FROM posts WHERE thread_id = ?",
+ "SELECT COUNT(*) AS total FROM threads WHERE space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status)",
+ "SELECT author_subject AS s, COUNT(*) AS posts, MIN(created_at) AS first_post_at FROM posts WHERE author_subject IN (?) AND deleted_at IS NULL GROUP BY author_subject",
+ "SELECT author_subject FROM posts WHERE id = ?",
+ "SELECT body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1",
+ "SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count FROM categories c WHERE c.space_id = ? ORDER BY c.position, c.name COLLATE NOCASE",
+ "SELECT copies FROM pastes WHERE id = ?",
+ "SELECT cursor, latest_seq FROM relay_cursors WHERE name = ?",
+ "SELECT f.*, s.slug AS space_slug FROM relay_inbound_failures f LEFT JOIN relay_mappings m ON m.id = f.mapping_id LEFT JOIN spaces s ON s.id = m.space_id WHERE (? = 1 OR f.dismissed_at IS NULL) ORDER BY f.id DESC LIMIT ?",
+ "SELECT hash FROM profile_module_pushes WHERE subject_id = ?",
+ "SELECT i.* FROM pulse_items i WHERE (@origin IS NULL OR i.origin = @origin) AND (@at IS NULL OR i.occurred_at < @at OR (i.occurred_at = @at AND i.id < @id)) AND (i.source_service <> 'community' OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public')) OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.visibility = 'public' AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL)) OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public' AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))) ORDER BY i.occurred_at DESC, i.id DESC LIMIT @limit",
+ "SELECT id FROM comment_threads WHERE access_id IS NULL",
+ "SELECT id FROM posts WHERE thread_id = ?",
+ "SELECT id FROM space_groups WHERE slug = ?",
+ "SELECT id FROM threads",
+ "SELECT id FROM threads WHERE space_id = ?",
+ "SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1",
+ "SELECT id, legacy_media_id FROM pastes WHERE slug = ?",
+ "SELECT id, slug, visibility, burn_after_read, deleted_at FROM pastes WHERE slug = ?",
+ "SELECT last_at FROM paste_visits WHERE paste_id = ? AND visitor = ?",
+ "SELECT name FROM sqlite_master WHERE type = 'table'",
+ "SELECT p.author_subject AS s, r.reaction, COUNT(*) AS n FROM post_reactions r JOIN posts p ON p.id = r.post_id WHERE p.author_subject IN (?) AND p.deleted_at IS NULL GROUP BY p.author_subject, r.reaction",
+ "SELECT post_id, reaction, subject_id FROM post_reactions WHERE post_id IN (?) ORDER BY created_at, subject_id",
+ "SELECT pp.post_id, p.slug, p.title, p.language, p.type, p.screenshot_url, p.visibility, p.content FROM post_pastes pp JOIN pastes p ON p.id = pp.paste_id WHERE pp.post_id IN (?) AND p.deleted_at IS NULL AND p.visibility != 'private' AND p.burn_after_read = 0 ORDER BY pp.post_id, pp.position",
+ "SELECT reaction FROM post_reactions WHERE post_id = ? AND subject_id = ?",
+ "SELECT revision, body_markdown, edited_by, created_at FROM post_versions WHERE post_id = ? ORDER BY revision ASC",
+ "SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC",
+ "SELECT s.*, g.slug AS group_slug, g.name AS group_name, g.description AS group_description, g.position AS group_position, ps.slug AS parent_slug, ps.name AS parent_name, (SELECT COUNT(*) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS thread_count, (SELECT COUNT(*) FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = s.id AND t.deleted_at IS NULL AND p.deleted_at IS NULL) AS post_count, (SELECT MAX(t.last_activity_at) FROM threads t WHERE t.space_id = s.id AND t.deleted_at IS NULL) AS last_activity_at FROM spaces s LEFT JOIN space_groups g ON g.id = s.group_id LEFT JOIN spaces ps ON ps.id = s.parent_id WHERE s.visibility IN (?) ORDER BY g.position IS NULL, g.position, s.position, s.id ASC",
+ "SELECT slug, name FROM space_groups WHERE id = ?",
+ "SELECT slug, name FROM spaces WHERE id = ?",
+ "SELECT slug, name, description, position FROM space_groups WHERE slug = ?",
+ "SELECT slug, visibility FROM spaces WHERE id = ?",
+ "SELECT status, COUNT(*) AS n FROM relay_deliveries GROUP BY status",
+ "SELECT subject_id FROM subject_projection WHERE username = ? COLLATE NOCASE ORDER BY refreshed_at DESC",
+ "SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.deleted_at IS NULL AND s.visibility IN (?) AND (? IS NULL OR s.slug = ?) AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL ORDER BY t.created_at DESC, t.id DESC LIMIT ?",
+ "SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_id FROM posts p JOIN threads t ON t.id = p.thread_id WHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULL ORDER BY p.id DESC LIMIT 1",
+ "SELECT t.slug, t.title, s.slug AS space_slug FROM threads t JOIN spaces s ON s.id = t.space_id WHERE t.crosspost_of = ? AND t.deleted_at IS NULL ORDER BY t.id",
+ "SELECT target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?",
+ "SELECT threads.*, (SELECT p.author_subject FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_author_subject, (SELECT p.origin FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_origin, (SELECT p.id FROM posts p WHERE p.thread_id = threads.id AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 1) AS last_post_id FROM threads WHERE space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status) ORDER BY pinned DESC, last_activity_at DESC, id DESC LIMIT @limit OFFSET @offset",
+ "SELECT title FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility != 'private' AND burn_after_read = 0",
+ "SELECT valid_after_ms FROM token_revocations WHERE subject_id = ?",
+ "SELECT views, unique_views FROM pastes WHERE id = ?",
+ "UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL",
+ "UPDATE comment_threads SET access_id = ? WHERE id = ?",
+ "UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE comment_threads SET ref_label = ?, updated_at = CURRENT_TIMESTAMP WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, '') <> ?",
+ "UPDATE comment_threads SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP, deleted_by = ?, message = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE comments SET message = ?, edited_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL",
+ "UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?",
+ "UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?",
+ "UPDATE paste_comments SET parent_id = ? WHERE id = ? AND parent_id IS NOT ?",
+ "UPDATE pastes SET copies = copies + 1 WHERE id = ?",
+ "UPDATE pastes SET forked_from = ? WHERE id = ? AND forked_from IS NOT ?",
+ "UPDATE pastes SET views = views + 1, unique_views = unique_views + 1 WHERE id = ?",
+ "UPDATE posts SET body_markdown = ?, revision = revision + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE posts SET deleted_at = CURRENT_TIMESTAMP, body_markdown = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE pulse_items SET title = ?, url = ? WHERE source_service = ? AND source_type = ? AND source_id = ?",
+ "UPDATE relay_deliveries SET status = 'dropped', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IN ('pending', 'failed')",
+ "UPDATE relay_deliveries SET status = 'pending', attempts = 0, next_attempt_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status <> 'delivered'",
+ "UPDATE relay_inbound_failures SET dismissed_at = CURRENT_TIMESTAMP WHERE id = ? AND dismissed_at IS NULL",
+ "UPDATE relay_message_map SET external_deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND external_deleted_at IS NULL",
+ "UPDATE relay_message_map SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE spaces SET group_id = (SELECT id FROM space_groups WHERE slug = ?), position = ?, style = ?, votes = ? WHERE slug = ? AND created_by = 'system' AND group_id IS NULL",
+ "UPDATE spaces SET members_only_owner = ? WHERE id = ?",
+ "UPDATE threads SET category_id = ? WHERE id = ?",
+ "UPDATE threads SET category_id = NULL WHERE category_id = ?",
+ "UPDATE threads SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL",
+ "UPDATE threads SET members_only_owner = ? WHERE id = ?",
+ "UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?",
+ "UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE threads SET status = ? WHERE id = ?",
+ "UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?",
+ "UPDATE threads SET views = views + 1 WHERE id = ?"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+'use strict';
+/**
+ * N-1 (roadmap WS-P task 11, ADR-016): during a deploy, and for the 24-hour mixed-version window after
+ * it, open tabs run the previous release's client against this release's server, and a previous-release
+ * process can still be working on the database this release migrated. Both must keep working.
+ *
+ * - An N-1 client against the N server: test/fixtures/n-1/client.json is every call the previous
+ * release's client code makes (found statically, made concrete with seeded values) with what that
+ * release answered: status, JSON or not, and the response fields the old client reads with their
+ * types. This checkout boots (test/n-1/service.js) and must answer each call compatibly: any 2xx
+ * for a 2xx, the same status otherwise (a 5xx only needs the route), JSON where there was JSON,
+ * every read field still there with a type it had. The run sits inside openvibe-shared's
+ * mixed-version matrix (release-compat assertMixedVersion) with both releases' manifests.
+ * - N-1 SQL against the N schema: test/fixtures/n-1/worker.json holds the previous release's schema
+ * (and migration ledger, and what it creates on first use) and every statement it ran or has as a
+ * literal. The database this test boots on is created from that schema, so this release's
+ * migrations run over it; afterwards every N-1 statement must still prepare (no table or column it
+ * names is gone), and no N-1 INSERT may miss a column this release made NOT NULL without a default.
+ *
+ * The fixtures come from the release in production: `npm run n-1:record` after each deploy (see
+ * scripts/n-1-record.js). This test needs no git history and no network.
+ *
+ * node test/n-1.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const Database = require('better-sqlite3');
+const compat = require('openvibe-shared/release-compat');
+const h = require('./n-1/harness');
+const svc = require('./n-1/service');
+
+const ROOT = path.join(__dirname, '..');
+const FIX = path.join(__dirname, 'fixtures', 'n-1');
+const client = JSON.parse(fs.readFileSync(path.join(FIX, 'client.json'), 'utf8'));
+const worker = JSON.parse(fs.readFileSync(path.join(FIX, 'worker.json'), 'utf8'));
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${String(err.message).split('\n').join('\n ')}`); }
+}
+
+(async () => {
+ const age = Math.round((Date.now() - Date.parse(client.recorded_at)) / 86400000);
+ console.log(`n-1: ${svc.service} N-1 = ${client.release.slice(0, 12)}, recorded ${client.recorded_at.slice(0, 10)} (${age} d ago); ${client.calls.length} calls, ${worker.statements.length} statements`);
+
+ console.log('n-1: the harness');
+ await check('finds call sites, their method and template, and skips computed paths', () => {
+ const files = [{ name: 'a.js', text: "api('/streams'); api(`/users/${encodeURIComponent(u.username)}/follow`, { method: 'POST', body: {} }); fetch(`${API}/api/x?limit=${n}`); api(path); fetch('https://elsewhere/x'); api(`/b/${on ? 'mute' : 'unmute'}`, { method: 'post' });" }];
+ const { calls, dynamic } = h.extractCalls({ files, callers: [{ name: 'api', prefix: '/api' }, { name: 'fetch' }], strip: ['API'] });
+ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.template}`), ['POST /api/b/{on ? \'mute\' : \'unmute\'}', 'GET /api/streams', 'POST /api/users/{encodeURIComponent(u.username)}/follow', 'GET /api/x?limit={n}']);
+ assert.strictEqual(dynamic, 1);
+ const samples = [[/id\)*$/i, '1'], [/name\)*$/i, 'n1star']];
+ assert.deepStrictEqual(calls.map((c) => h.concretePath(c.parts, samples)), ['/api/b/mute', '/api/streams', '/api/users/n1star/follow', '/api/x?limit=']);
+ });
+ await check('reads: the key paths the client names; a gone or retyped one is a problem, an empty list or null parent is not', () => {
+ const ids = h.clientIdentifiers(['x.streams.map((s) => s.title); const { total } = d; s?.viewer_count']);
+ const reads = h.readsOf({ streams: [{ title: 'a', viewer_count: 1, secret: 'k' }], total: 3, other: 1 }, ids);
+ assert.deepStrictEqual(reads, { streams: 'array', 'streams[]': 'object', 'streams[].title': 'string', 'streams[].viewer_count': 'number', total: 'number' });
+ assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: 2 }], total: 1 }), []);
+ assert.deepStrictEqual(h.readProblems(reads, { streams: [], total: 0 }), []);
+ assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: '2' }] }), ['streams[].viewer_count was number, now string', 'total is gone']);
+ assert.ok(h.statusCompatible(200, 201) && h.statusCompatible(502, 503) && !h.statusCompatible(401, 403) && !h.statusCompatible(502, 404) && !h.statusCompatible(200, 404));
+ });
+ await check('SQL: a dropped column fails the prepare; a new NOT NULL column without a default fails an old INSERT', () => {
+ const d = new Database(':memory:');
+ d.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT NOT NULL, c TEXT NOT NULL DEFAULT \'x\')');
+ assert.deepStrictEqual(h.prepareProblems(d, ['SELECT a, gone FROM t']).map((p) => p.error), ['no such column: gone']);
+ assert.deepStrictEqual(h.insertProblems(d, ['INSERT INTO t (a, b) VALUES (?, ?)', 'INSERT INTO t (a) VALUES (?)']).map((p) => p.error), ['t.b is NOT NULL without a default, and N-1 does not set it']);
+ d.close();
+ });
+
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-test-'));
+ const dbPath = path.join(tmp, 'db', `${svc.service}.db`);
+ const dataDir = path.join(tmp, 'data');
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+ fs.mkdirSync(dataDir, { recursive: true });
+ let server = null;
+ try {
+ console.log(`n-1: this release on a database N-1 created`);
+ await check('N-1\'s schema, ledger and first-use tables load, and this release migrates and seeds it', () => {
+ const d = new Database(dbPath);
+ d.transaction(() => {
+ for (const ddl of worker.schema) d.exec(ddl);
+ for (const [table, rows] of Object.entries(worker.ledger || {})) {
+ for (const row of rows) {
+ const cols = Object.keys(row);
+ d.prepare(`INSERT INTO "${table}" (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...cols.map((c) => row[c]));
+ }
+ }
+ for (const ddl of worker.lazy || []) d.exec(ddl);
+ })();
+ d.pragma(`user_version = ${Number(worker.user_version) || 0}`);
+ d.close();
+ svc.seed({ dir: ROOT, dbPath, dataDir });
+ });
+ server = await svc.boot({ dir: ROOT, dbPath, dataDir });
+ const manifest = await (await fetch(`${server.url}/release.json`)).json();
+
+ console.log('n-1: the N-1 client against this server');
+ const sockets = client.ws ? `, and ${client.ws.sends.length} socket message types in ${client.ws.sessions.length} sessions` : '';
+ await check(`every call the N-1 client makes is answered compatibly (${client.calls.length} requests${sockets}, in openvibe-shared's mixed-version matrix)`, async () => {
+ const broken = [];
+ const n1Client = async (url) => {
+ const probe = await h.notFoundProbe(url);
+ for (const rec of client.calls) {
+ const got = await h.send(url, { method: rec.method, path: h.fillPath(rec.path, server.ids), headers: { ...(rec.accept ? { accept: 'text/html' } : {}), ...server.headers(rec.auth) }, body: rec.method === 'GET' ? undefined : {} });
+ const problems = h.callProblems(rec, got, probe);
+ if (problems.length) broken.push(`${rec.auth} ${rec.method} ${rec.path} (${rec.from.join(', ')}): ${problems.join('; ')}`);
+ }
+ for (const session of (client.ws && client.ws.sessions) || []) {
+ const messages = h.wsMessages(client.ws.sends, { order: svc.ws.order, values: server.wsValues(session.auth) });
+ const received = await h.wsSession({ url: url.replace(/^http/, 'ws') + client.ws.path, headers: svc.ws.headers, messages });
+ broken.push(...h.wsProblems(session.received, received).map((p) => `socket ${client.ws.path} (${session.auth}): ${p}`));
+ }
+ if (broken.length) throw new Error(`${broken.length} broken:\n${h.summarize(broken)}`);
+ };
+ await compat.assertMixedVersion({ releases: [
+ { name: 'N-1', manifest: client.manifest, client: n1Client },
+ { name: 'N', manifest, server: async () => ({ url: server.url }) },
+ ] });
+ });
+ await server.close();
+ server = null;
+
+ console.log('n-1: N-1\'s SQL on the schema this release migrated');
+ await check(`every statement N-1 runs still prepares (${worker.statements.length}), and none of its INSERTs misses a new required column`, () => {
+ const d = new Database(dbPath, { readonly: true });
+ const problems = [...h.prepareProblems(d, worker.statements), ...h.insertProblems(d, worker.statements)];
+ d.close();
+ assert.deepStrictEqual(problems.map((p) => `${p.error}: ${p.sql.slice(0, 160)}`), [], 'N-1 SQL that breaks on this schema (expand first, contract a release later: ADR-028)');
+ });
+ } finally {
+ if (server) { console.log(server.log().slice(-1500)); await server.close(); }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+
+ if (failures) { console.log(`\nn-1: ${failures} check(s) failed`); process.exit(1); }
+ console.log('\nn-1: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+/**
+ * Runs inside a Community release (its directory is the cwd): starts that release's own Live, Network
+ * and Media mocks (test/helpers/mock-*.js), then the app as production runs it (pastes authority
+ * community, the shared database at COMMUNITY_DB_PATH = N1_DB), seeds a thread, a paste and a comment
+ * through the API, and prints one line `{"n1": { url, token, ids }}`. SIGTERM stops it.
+ */
+const http = require('http');
+const path = require('path');
+
+(async () => {
+ const dir = process.cwd();
+ console.warn = () => {};
+ const mockLive = require(path.join(dir, 'test', 'helpers', 'mock-live'));
+ const mockNetwork = require(path.join(dir, 'test', 'helpers', 'mock-network'));
+ const mockMedia = require(path.join(dir, 'test', 'helpers', 'mock-media'));
+ const liveSrv = await mockLive.start();
+ const net = await mockNetwork.start();
+ const mediaSrv = await mockMedia.start({ publicPem: net.publicPem, issuer: net.url });
+ Object.assign(process.env, {
+ NODE_ENV: 'test',
+ BASE_URL: 'https://openvibe.community',
+ OV_LIVE_INTERNAL_URL: liveSrv.url,
+ OV_LIVE_URL: 'https://openvibe.live',
+ OV_MEDIA_URL: 'https://openvibe.media',
+ OV_NETWORK_URL: net.url,
+ OV_NETWORK_INTERNAL_URL: net.url,
+ OV_OAUTH_CLIENT_ID: 'community',
+ OV_OAUTH_CLIENT_SECRET: 'shh',
+ OV_OAUTH_REDIRECT_URI: 'https://openvibe.community/auth/callback',
+ COOKIE_SECURE: 'true',
+ TRUST_PROXY: '1',
+ OV_MEDIA_INTERNAL_URL: mediaSrv.url,
+ PASTES_AUTHORITY: 'community',
+ COMMUNITY_DB_PATH: process.env.N1_DB,
+ });
+ const { createApp } = require(path.join(dir, 'server', 'app'));
+ const app = createApp({
+ pasteLimits: { cooldownSeconds: 0 },
+ forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 },
+ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },
+ });
+ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const url = `http://127.0.0.1:${server.address().port}`;
+
+ const star = net.addUser({ network_user_id: 7, username: 'n1star', display_name: 'N1 Star' });
+ const token = net.sign({ id: 7, subject_id: star.subject_id, username: 'n1star', display_name: 'N1 Star', role: 'user' });
+ const call = async (p, { method = 'GET', json } = {}) => {
+ const res = await fetch(url + p, { method, headers: { cookie: `ov_token=${token}`, ...(json ? { 'content-type': 'application/json' } : {}) }, body: json ? JSON.stringify(json) : undefined });
+ let body = null; try { body = await res.json(); } catch { /* */ }
+ if (!res.ok) process.stderr.write(`[n-1] seed ${method} ${p}: ${res.status} ${JSON.stringify(body).slice(0, 200)}\n`);
+ return body || {};
+ };
+ const thread = (await call('/api/v1/spaces/general/threads', { method: 'POST', json: { title: 'N-1 thread', body: 'Seeded for the N-1 test' } })).thread;
+ if (thread) await call(`/api/v1/threads/${thread.id}/posts`, { method: 'POST', json: { body: 'A reply from N-1' } });
+ const paste = (await call('/api/pastes', { method: 'POST', json: { title: 'N-1 paste', content: 'console.log("n-1")', language: 'javascript' } })).paste;
+ const ref = { service: 'live', type: 'stream', id: '123' };
+ const commentThread = (await call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref } })).thread;
+ if (commentThread) await call(`/api/v1/comments/threads/${commentThread.id}/comments`, { method: 'POST', json: { body: 'A comment from N-1' } });
+
+ process.on('SIGTERM', () => { server.close(); try { require(path.join(dir, 'server', 'db')).closeDb(); } catch { /* */ } process.exit(0); });
+ process.stdout.write(`${JSON.stringify({ n1: { url, token, ids: { thread: thread && thread.id, thread_slug: thread && thread.slug, paste: paste && paste.slug, comment_thread: commentThread && commentThread.id, comment_access: commentThread && (commentThread.access_id || commentThread.id) } } })}\n`);
+})().catch((err) => { process.stderr.write(`${err.stack || err.message}\n`); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,855 @@
@@ -0,0 +1,855 @@
+'use strict';
+/**
+ * N-1 harness (roadmap WS-P task 11, ADR-016's mixed-version window): the previous release's client
+ * against this release's server, and the previous release's SQL against this release's schema.
+ * The same file in Live, Chat, Media and Community; each repository's test/n-1/service.js says what
+ * its clients are, how to boot and seed it, and where its SQL lives.
+ *
+ * scripts/n-1-record.js <ref> at release time: checks out <ref> (the release in production, which
+ * is N-1 for the next deploy) into a temporary worktree, boots it, and
+ * writes test/fixtures/n-1/{client,worker}.json
+ * test/n-1.test.js in npm test and CI: boots this checkout on a database created with
+ * N-1's schema and replays the fixture; no git history, no network
+ *
+ * Client calls are found statically in the N-1 client code (the call sites of `api(...)`, `fetch(...)`
+ * and the service's other wrappers, with a literal or template path), made concrete with sample values,
+ * and replayed against the booted N-1 server: every call anonymously, reads again signed in. What the
+ * fixture keeps per call is what an old client depends on: the status, whether the answer is JSON,
+ * and the response fields it reads (the JSON key paths whose every key the N-1 client code names, with
+ * their types).
+ *
+ * SQL is every statement N-1 prepared while it served those calls, plus every literal SQL string in its
+ * server code that prepares on its own schema. The N-1 schema is its sqlite_master after the replay.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+// ── Client calls: static extraction ──────────────────────────
+
+/** Reads a JS string/template literal at src[i] (a quote). → { end, parts: [{ text } | { expr }] } or null. */
+function readLiteral(src, i) {
+ const q = src[i];
+ if (q !== '\'' && q !== '"' && q !== '`') return null;
+ const parts = [];
+ let text = '';
+ let j = i + 1;
+ while (j < src.length) {
+ const c = src[j];
+ if (c === '\\') { text += unescapeChar(src[j + 1]); j += 2; continue; }
+ if (c === q) { if (text) parts.push({ text }); return { end: j + 1, parts }; }
+ if (q !== '`' && c === '\n') return null;
+ if (q === '`' && c === '$' && src[j + 1] === '{') {
+ if (text) parts.push({ text });
+ text = '';
+ const close = skipBalanced(src, j + 1, '{', '}');
+ if (close < 0) return null;
+ parts.push({ expr: src.slice(j + 2, close).trim() });
+ j = close + 1;
+ continue;
+ }
+ text += c;
+ j++;
+ }
+ return null;
+}
+
+function unescapeChar(c) {
+ return { n: '\n', t: '\t', r: '\r', '0': '\0' }[c] ?? c ?? '';
+}
+
+/** Index of the bracket closing the one at src[i] (open), skipping strings and templates; -1 if none. */
+function skipBalanced(src, i, open, close) {
+ let depth = 0;
+ for (let j = i; j < src.length; j++) {
+ const c = src[j];
+ if (c === '\'' || c === '"' || c === '`') {
+ const lit = readLiteral(src, j);
+ if (lit) { j = lit.end - 1; continue; }
+ }
+ if (c === open) depth++;
+ else if (c === close) { depth--; if (depth === 0) return j; }
+ }
+ return -1;
+}
+
+/** Top-level comma-separated arguments of a call whose '(' is at src[i]. */
+function callArgs(src, i) {
+ const end = skipBalanced(src, i, '(', ')');
+ if (end < 0) return null;
+ const args = [];
+ let depth = 0; let start = i + 1;
+ for (let j = i + 1; j < end; j++) {
+ const c = src[j];
+ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }
+ if (c === '(' || c === '{' || c === '[') depth++;
+ else if (c === ')' || c === '}' || c === ']') depth--;
+ else if (c === ',' && depth === 0) { args.push(src.slice(start, j).trim()); start = j + 1; }
+ }
+ const last = src.slice(start, end).trim();
+ if (last) args.push(last);
+ return args;
+}
+
+const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/** src split at every top-level `sep` (outside strings and brackets). */
+function splitTop(src, sep) {
+ const out = [];
+ let depth = 0; let start = 0;
+ for (let j = 0; j < src.length; j++) {
+ const c = src[j];
+ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }
+ if (c === '(' || c === '{' || c === '[') depth++;
+ else if (c === ')' || c === '}' || c === ']') depth--;
+ else if (c === sep && depth === 0) { out.push(src.slice(start, j)); start = j + 1; }
+ }
+ out.push(src.slice(start));
+ return out.map((x) => x.trim());
+}
+
+/**
+ * The parts of a path expression: a literal, a template, or literals, templates and names joined with
+ * `+` ('/api/x/' + id + '?a=1'); a name in `vars` stands for the path it was assigned. → parts or null
+ */
+function pathParts(expr, vars = new Map()) {
+ const parts = [];
+ for (const piece of splitTop(expr, '+')) {
+ if (!piece) return null;
+ const lit = /^['"`]/.test(piece) ? readLiteral(piece, 0) : null;
+ if (lit && lit.end === piece.length) parts.push(...lit.parts.flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x])));
+ else if (vars.has(piece)) parts.push(...vars.get(piece));
+ else parts.push({ expr: piece });
+ }
+ const merged = [];
+ for (const p of parts) {
+ const last = merged[merged.length - 1];
+ if (p.text != null && last && last.text != null) last.text += p.text; else merged.push({ ...p });
+ }
+ return merged;
+}
+
+/** `esc(base)`, `encodeURIComponent(base)`: the name inside one escaping call. */
+function unwrap(expr) {
+ const m = /^(?:esc|enc|encodeURI|encodeURIComponent|String|escapeHtml|escHtml)\(\s*([A-Za-z_$][\w$]*)\s*\)$/.exec(String(expr).trim());
+ return m ? m[1] : String(expr).trim();
+}
+
+/** `var|let|const name = <path expression>` in a file, for names later calls use as a path base. */
+function pathVars(src) {
+ const vars = new Map();
+ for (const m of src.matchAll(/(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*(?=['"`])/g)) {
+ let depth = 0; let end = src.length;
+ for (let j = m.index + m[0].length; j < src.length; j++) {
+ const c = src[j];
+ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }
+ if (c === '(' || c === '{' || c === '[') depth++;
+ else if (c === ')' || c === '}' || c === ']') { if (depth === 0) { end = j; break; } depth--; } else if (depth === 0 && (c === ';' || c === '\n' || c === ',')) { end = j; break; }
+ }
+ const parts = pathParts(src.slice(m.index + m[0].length, end).trim(), vars);
+ if (parts && parts[0] && parts[0].text && parts[0].text.startsWith('/') && !parts[0].text.startsWith('//')) vars.set(m[1], parts);
+ }
+ return vars;
+}
+
+/**
+ * Every call site of the service's request functions whose path is a literal, a template or a
+ * concatenation starting with one (or with a variable assigned one).
+ * callers: [{ name: 'api', prefix: '/api', method?: 'POST', methodArg?: 1 }]
+ * strip: template expressions dropped when a path starts with them (e.g. 'API', 'location.origin')
+ * keep(pathname, method, file): whether a call is this server's
+ * → { calls: [{ method, template, parts, from: [file] }], dynamic: calls with a computed path }
+ */
+function extractCalls({ files, callers, strip = [], keep = (p) => true, forms = false }) {
+ const found = new Map();
+ let dynamic = 0;
+ for (const { name: file, text: src } of files) {
+ const vars = pathVars(src);
+ for (const caller of callers) {
+ const re = new RegExp(`(?<![\\w$.])${esc(caller.name)}\\s*\\(`, 'g');
+ let m;
+ while ((m = re.exec(src))) {
+ const open = m.index + m[0].length - 1;
+ const args = callArgs(src, open);
+ if (!args || !args.length) continue;
+ // An SDK-style call: call({ method: 'POST', path: `/api/x/${id}`, … }).
+ let pathExpr = args[0];
+ let objMethod = null;
+ if (caller.object) {
+ if (!/^\{[\s\S]*\}$/.test(args[0])) continue; // some other function of that name
+ const entries = splitTop(args[0].slice(1, -1), ',');
+ const entry = (k) => { const e = entries.find((x) => new RegExp(`^${k}\\s*:`).test(x)); return e ? e.replace(new RegExp(`^${k}\\s*:\\s*`), '') : null; };
+ pathExpr = entry('path');
+ const me = entry('method');
+ const ml = me && /^['"`]/.test(me) ? readLiteral(me, 0) : null;
+ if (ml && ml.parts.length === 1 && ml.parts[0].text) objMethod = ml.parts[0].text;
+ if (!pathExpr) continue;
+ }
+ let parts = pathParts(pathExpr, vars);
+ if (!parts) { dynamic++; continue; }
+ while (parts.length && parts[0].expr && strip.includes(parts[0].expr)) parts.shift();
+ if (!parts.length || !parts[0].text || !parts[0].text.startsWith('/') || parts[0].text.startsWith('//')) {
+ if (parts.length && parts[0].expr) dynamic++;
+ continue;
+ }
+ parts = caller.prefix ? [{ text: caller.prefix }, ...parts] : parts;
+ if (parts[1] && parts[0].text != null && parts[1].text != null) parts = [{ text: parts[0].text + parts[1].text }, ...parts.slice(2)];
+ // `/api${path}`: a wrapper passing a path through, not a call.
+ if (parts.some((p, k) => p.expr && k > 0 && parts[k - 1].text != null && !parts.slice(0, k).some((x) => x.text && x.text.includes('?')) && /[A-Za-z0-9]$/.test(parts[k - 1].text))) { dynamic++; continue; }
+ const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('').replace(/^([^?#]*)\/\//, '$1/');
+ let method = objMethod || caller.method || 'GET';
+ if (caller.object) { /* the method was in the object */ } else if (caller.methodArg != null) {
+ const a = args[caller.methodArg];
+ const ml = a && /^['"`]/.test(a) ? readLiteral(a, 0) : null;
+ if (ml && ml.parts.length === 1 && ml.parts[0].text) method = ml.parts[0].text;
+ } else {
+ const opts = args.slice(1).join(',');
+ const mm = /\bmethod\s*:\s*['"`]([A-Za-z]+)['"`]/.exec(opts);
+ if (mm) method = mm[1];
+ }
+ method = method.toUpperCase();
+ if (!keep(template.split('?')[0], method, file)) continue;
+ const key = `${method} ${template}`;
+ if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });
+ found.get(key).from.add(file);
+ }
+ }
+ }
+ // Forms the pages post (or get) to: <form action="/x/${id}" method="post"> in markup or a template.
+ if (forms) {
+ for (const { name: file, text: src } of files) {
+ const vars = pathVars(src);
+ for (const m of src.matchAll(/<form\b/gi)) {
+ const end = tagEnd(src, m.index);
+ if (end < 0) continue;
+ const tag = src.slice(m.index, end);
+ const action = attr(tag, 'action');
+ if (!action || action.startsWith('//') || /^[a-z]+:/i.test(action)) continue;
+ const parts = templateParts(action).flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x]));
+ if (!parts.length || parts[0].text == null || !parts[0].text.startsWith('/')) continue;
+ const methodAttr = attr(tag, 'method');
+ const method = /^post$/i.test(methodAttr || '') ? 'POST' : 'GET';
+ const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('');
+ if (!keep(template.split('?')[0], method, file)) continue;
+ const key = `${method} ${template}`;
+ if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });
+ found.get(key).from.add(file);
+ }
+ }
+ }
+ const calls = [...found.values()].map((c) => ({ ...c, from: [...c.from].sort() }))
+ .sort((a, b) => (a.template < b.template ? -1 : a.template > b.template ? 1 : a.method < b.method ? -1 : 1));
+ return { calls, dynamic };
+}
+
+/** Where an HTML tag that starts at src[i] ends ('>' outside quotes and ${…}); -1 if it does not. */
+function tagEnd(src, i) {
+ let q = null;
+ for (let j = i; j < src.length; j++) {
+ const c = src[j];
+ if (c === '$' && src[j + 1] === '{') { const close = skipBalanced(src, j + 1, '{', '}'); if (close < 0) return -1; j = close; continue; }
+ if (q) { if (c === q) q = null; continue; }
+ if (c === '"' || c === '\'') q = c;
+ else if (c === '>') return j + 1;
+ }
+ return -1;
+}
+
+/** An attribute's raw value in a tag (quoted, may hold ${…}). */
+function attr(tag, name) {
+ const m = new RegExp(`\\s${name}\\s*=\\s*(["'])`, 'i').exec(tag);
+ if (!m) return null;
+ const q = m[1];
+ let out = '';
+ for (let j = m.index + m[0].length; j < tag.length; j++) {
+ const c = tag[j];
+ if (c === '$' && tag[j + 1] === '{') { const close = skipBalanced(tag, j + 1, '{', '}'); if (close < 0) return null; out += tag.slice(j, close + 1); j = close; continue; }
+ if (c === q) return out;
+ out += c;
+ }
+ return null;
+}
+
+/** Text with ${expr} holes → parts. */
+function templateParts(text) {
+ const parts = [];
+ let buf = '';
+ for (let j = 0; j < text.length; j++) {
+ if (text[j] === '$' && text[j + 1] === '{') {
+ const close = skipBalanced(text, j + 1, '{', '}');
+ if (close < 0) break;
+ if (buf) parts.push({ text: buf });
+ buf = '';
+ parts.push({ expr: text.slice(j + 2, close).trim() });
+ j = close;
+ continue;
+ }
+ buf += text[j];
+ }
+ if (buf) parts.push({ text: buf.replace(/&/g, '&') });
+ return parts;
+}
+
+/**
+ * A concrete path for a template: each ${expr} becomes the first sample whose pattern matches the
+ * expression text, else '1' in the path and '' in the query.
+ */
+function concretePath(parts, samples = []) {
+ let out = '';
+ for (const p of parts) {
+ if (p.text != null) { out += p.text; continue; }
+ const inQuery = out.includes('?');
+ // `${on ? 'mute' : 'unmute'}`: the first branch.
+ const branch = /\?\s*(['"`])([^'"`]*)\1\s*:/.exec(p.expr);
+ if (branch) { out += branch[2]; continue; }
+ const hit = samples.find(([re]) => re.test(p.expr));
+ let v = hit ? hit[1] : (inQuery ? '' : '1');
+ if (typeof v === 'function') v = v(p.expr, inQuery);
+ // '@name': a value the booted server seeded (a random slug); filled in when the call is sent.
+ if (/^@\w+$/.test(String(v))) { out += `{${v}}`; continue; }
+ out += inQuery ? String(v) : encodeURIComponent(String(v)).replace(/%2F/gi, '/');
+ }
+ out = out.replace(/^([^?#]*)\/\/+/, '$1/').replace(/[?&]+$/, '').replace(/\?&+/, '?');
+ return out;
+}
+
+/**
+ * The same-origin URLs a served page makes the browser fetch or offers to follow: src, href, poster,
+ * data-src and form actions (with their method). `origins` are absolute origins that count as this
+ * site (e.g. its public URL). → [{ method, path }]
+ */
+function pageLinks(html, origins = []) {
+ const out = new Map();
+ const add = (method, raw) => {
+ let u = String(raw || '').trim().replace(/&/g, '&');
+ if (!u || /^(#|mailto:|javascript:|data:|tel:|blob:)/i.test(u)) return;
+ const o = origins.find((x) => u.startsWith(`${x}/`) || u === x);
+ if (o) u = u.slice(o.length) || '/';
+ if (!u.startsWith('/') || u.startsWith('//')) return;
+ u = u.split('#')[0];
+ out.set(`${method} ${u}`, { method, path: u });
+ };
+ for (const m of String(html).matchAll(/<([a-zA-Z][\w-]*)\b([^>]*)>/g)) {
+ const tag = m[1].toLowerCase(); const attrs = m[2];
+ const get = (n) => { const a = new RegExp(`\\s${n}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs); return a ? (a[1] != null ? a[1] : a[2]) : null; };
+ if (tag === 'form') { const a = get('action'); if (a != null) add(/^post$/i.test(get('method') || '') ? 'POST' : 'GET', a); continue; }
+ for (const n of ['src', 'href', 'poster', 'data-src']) { const v = get(n); if (v != null) add('GET', v); }
+ }
+ return [...out.values()];
+}
+
+/** A path with the seeded ids that differ per run (random slugs, keys) put back as {@name} slots. */
+function unfillPath(p, ids = {}) {
+ let out = String(p);
+ for (const [name, v] of Object.entries(ids || {})) {
+ const s = v == null ? '' : String(v);
+ if (s.length < 6 || /^\d+$/.test(s)) continue;
+ out = out.split(encodeURIComponent(s)).join(`{@${name}}`).split(s).join(`{@${name}}`);
+ }
+ return out;
+}
+
+/** A recorded path with its {@name} slots filled from the booted server's seeded ids. */
+function fillPath(p, ids = {}) {
+ return String(p).replace(/\{@(\w+)\}/g, (m, name) => encodeURIComponent(ids[name] == null ? '0' : String(ids[name])));
+}
+
+/** Property names the client code reads: `.name`, `?.name`, `['name']`, 'name' literals, destructured names. */
+function clientIdentifiers(texts) {
+ const ids = new Set();
+ for (const t of texts) {
+ for (const m of t.matchAll(/\??\.\s*([A-Za-z_$][\w$]*)/g)) ids.add(m[1]);
+ for (const m of t.matchAll(/['"`]([A-Za-z_$][\w$]*)['"`]/g)) ids.add(m[1]);
+ for (const m of t.matchAll(/\{([^{}()]*)\}\s*(?:=[^=>]|\)\s*=>|\s*\))/g)) {
+ for (const part of m[1].split(',')) {
+ const id = /^\s*(?:\.\.\.)?\s*([A-Za-z_$][\w$]*)/.exec(part);
+ if (id) ids.add(id[1]);
+ }
+ }
+ }
+ return ids;
+}
+
+// ── Response shapes ──────────────────────────────────────────
+
+function typeOf(v) {
+ if (v === null) return 'null';
+ if (Array.isArray(v)) return 'array';
+ return typeof v;
+}
+
+/**
+ * The key paths of a JSON value → { 'a': 'object', 'a.b': 'string', 'list[]': 'object', 'list[].x': 'number' }.
+ * Array elements are merged: a path is present when any element has it; its types are all it takes.
+ */
+function flatten(value, maxDepth = 5) {
+ const out = {};
+ const add = (p, t) => { if (!out[p]) out[p] = new Set(); out[p].add(t); };
+ const walk = (v, prefix, depth) => {
+ if (depth > maxDepth) return;
+ if (Array.isArray(v)) {
+ for (const el of v) { add(`${prefix}[]`, typeOf(el)); walk(el, `${prefix}[]`, depth + 1); }
+ } else if (v && typeof v === 'object') {
+ for (const [k, x] of Object.entries(v)) {
+ const p = prefix ? `${prefix}.${k}` : k;
+ add(p, typeOf(x));
+ walk(x, p, depth + 1);
+ }
+ }
+ };
+ walk(value, '', 0);
+ return out;
+}
+
+/** The key paths of `value` an old client reads: every key along the path is one the client names. */
+function readsOf(value, ids) {
+ const flat = flatten(value);
+ const reads = {};
+ for (const [p, types] of Object.entries(flat)) {
+ const keys = p.split('.').map((k) => k.replace(/\[\]$/g, '')).filter(Boolean);
+ if (!keys.length || !keys.every((k) => ids.has(k))) continue;
+ const t = [...types].filter((x) => x !== 'null').sort();
+ reads[p] = t.length ? t.join('|') : 'null';
+ }
+ return reads;
+}
+
+/** What `reads` (recorded from N-1) finds missing or retyped in `value` (from N). */
+function readProblems(reads, value) {
+ const flat = flatten(value);
+ const problems = [];
+ // A path under an array N answers empty (or null) cannot be read by the old client either.
+ const emptyArray = (p) => {
+ const segs = p.split('.');
+ for (let i = 1; i <= segs.length; i++) {
+ const head = segs.slice(0, i).join('.');
+ if (!head.endsWith('[]')) continue;
+ const arr = head.slice(0, -2);
+ if (!flat[head] && (arr === '' ? Array.isArray(value) : flat[arr] && !flat[arr].has('object'))) return true;
+ }
+ return false;
+ };
+ const nullParent = (p) => {
+ const segs = p.split('.');
+ for (let i = 1; i < segs.length; i++) {
+ const head = segs.slice(0, i).join('.').replace(/\[\]$/, '');
+ if (flat[head] && [...flat[head]].every((t) => t === 'null')) return true;
+ }
+ return false;
+ };
+ for (const [p, want] of Object.entries(reads)) {
+ if (emptyArray(p) || nullParent(p)) continue;
+ const have = flat[p];
+ if (!have) { problems.push(`${p} is gone`); continue; }
+ if (want === 'null') continue;
+ const now = [...have].filter((t) => t !== 'null');
+ if (!now.length) continue;
+ const was = want.split('|');
+ if (!now.some((t) => was.includes(t))) problems.push(`${p} was ${want}, now ${now.join('|')}`);
+ }
+ return problems;
+}
+
+/** Whether N's status keeps N-1's promise: any 2xx for a 2xx, the same code otherwise; a 5xx only needs the route. */
+function statusCompatible(was, now) {
+ if (was >= 200 && was < 300) return now >= 200 && now < 300;
+ if (was >= 500) return now !== 404 && now !== 405;
+ return was === now;
+}
+
+// ── Replaying calls ──────────────────────────────────────────
+
+function kindOf(contentType, text) {
+ const ct = String(contentType || '').toLowerCase();
+ if (ct.includes('text/event-stream')) return 'sse';
+ if (ct.includes('json')) return 'json';
+ if (ct.includes('html')) return 'html';
+ if (!text) return 'empty';
+ if (ct.startsWith('text/')) return 'text';
+ return 'other';
+}
+
+/**
+ * Sends one call. → { status, kind, json, location }. Redirects are not followed (they may leave the
+ * sandbox); an event stream is not read past its headers.
+ */
+async function send(baseUrl, { method, path: p, headers = {}, body }, { timeoutMs = 8000 } = {}) {
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
+ try {
+ const hasBody = body !== undefined && method !== 'GET' && method !== 'HEAD';
+ const res = await fetch(new URL(p, baseUrl), {
+ method,
+ redirect: 'manual',
+ signal: ac.signal,
+ headers: { accept: 'application/json', ...(hasBody ? { 'content-type': 'application/json' } : {}), ...headers },
+ body: hasBody ? JSON.stringify(body) : undefined,
+ });
+ const ct = res.headers.get('content-type');
+ if (/event-stream/i.test(ct || '')) { ac.abort(); return { status: res.status, kind: 'sse', json: null }; }
+ const text = await res.text();
+ const kind = kindOf(ct, text);
+ let json = null;
+ if (kind === 'json') { try { json = JSON.parse(text); } catch { json = null; } }
+ const html = kind === 'html' ? text.slice(0, 1 << 20) : null;
+ const loc = res.headers.get('location');
+ // Where a redirect goes, without its query (a sign-in `next` or `state` differs every run).
+ return { status: res.status, kind, json, html, location: loc ? loc.replace(/^https?:\/\/[^/]+/, '').split('?')[0] : null };
+ } catch (err) {
+ return { status: 0, kind: 'error', json: null, error: err.name === 'AbortError' ? 'timeout' : err.message };
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/**
+ * The request list for a set of extracted calls: every call anonymously, GETs also signed in.
+ * Reads first, then writes (with an empty JSON body), so a write cannot change what a read sees.
+ */
+function requestsFor(calls, samples, { signedIn = true } = {}) {
+ const seen = new Set();
+ const reqs = [];
+ for (const c of calls) {
+ const p = concretePath(c.parts, samples);
+ for (const auth of c.method === 'GET' && signedIn ? ['anon', 'user'] : ['anon']) {
+ const key = `${c.method} ${p} ${auth}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ reqs.push({ method: c.method, path: p, auth, from: c.from });
+ }
+ }
+ const rank = (r) => (r.method === 'GET' ? 0 : 1);
+ return reqs.sort((a, b) => rank(a) - rank(b));
+}
+
+/**
+ * What the server answers a path nobody routes (per method): an HTML 404 is always "no route"; a
+ * JSON catch-all 404 (`{ error: 'Not found' }`) is recognised by its body. → [{ method, status, kind, json }]
+ */
+async function notFoundProbe(baseUrl, headers = {}) {
+ const out = [];
+ for (const method of ['GET', 'POST']) {
+ for (const p of ['/api/n-1-no-such-route', '/n-1-no-such-page']) {
+ const got = await send(baseUrl, { method, path: p, headers, body: method === 'GET' ? undefined : {} });
+ if (got.status === 404) out.push({ method, status: got.status, kind: got.kind, json: got.json });
+ }
+ }
+ return out;
+}
+
+/** Whether an answer is the server's unknown-path 404 rather than a route's own. */
+function isNoRoute(got, probe = []) {
+ if (got.status !== 404) return false;
+ if (got.kind !== 'json') return true;
+ return probe.some((fp) => fp.kind === 'json' && JSON.stringify(fp.json) === JSON.stringify(got.json));
+}
+
+/** The failures of one replayed call against its recorded outcome (probe: N's unknown-path answers). */
+function callProblems(rec, got, probe = []) {
+ const problems = [];
+ if (got.status === 0) return [`no answer (${got.error})`];
+ if (isNoRoute(got, probe)) return ['the route is gone (it answers as an unknown path)'];
+ if (!statusCompatible(rec.status, got.status)) problems.push(`answered ${got.status}, N-1 answered ${rec.status}`);
+ else if (rec.kind === 'json' && got.kind !== 'json' && rec.status < 500) problems.push(`answered ${got.kind}, N-1 answered JSON`);
+ if (rec.location && got.location && rec.status >= 300 && rec.status < 400 && got.location.split('?')[0] !== rec.location.split('?')[0]) problems.push(`redirects to ${got.location}, N-1 to ${rec.location}`);
+ if (rec.reads && got.json != null && got.status >= 200 && got.status < 300) problems.push(...readProblems(rec.reads, got.json));
+ return problems;
+}
+
+// ── WebSockets ───────────────────────────────────────────────
+
+/**
+ * The messages a client sends over its sockets: `x.send(JSON.stringify({ type: 'join', … }))` and
+ * `helper({ type: … })` for each helper name. → [{ type, keys: [...] }] (keys merged per type)
+ */
+function wsSends(files, helpers = []) {
+ const byType = new Map();
+ const add = (obj) => {
+ const t = obj.trim();
+ if (!t.startsWith('{') || !t.endsWith('}')) return;
+ let type = null; const keys = [];
+ for (const entry of splitTop(t.slice(1, -1), ',')) {
+ if (!entry || entry.startsWith('...')) continue;
+ const kv = /^(?:['"]?)([A-Za-z_$][\w$-]*)(?:['"]?)\s*(?::\s*([\s\S]*))?$/.exec(entry);
+ if (!kv) continue;
+ if (kv[1] === 'type') {
+ const lit = kv[2] && /^['"`]/.test(kv[2].trim()) ? readLiteral(kv[2].trim(), 0) : null;
+ if (lit && lit.parts.length === 1 && lit.parts[0].text) type = lit.parts[0].text;
+ } else keys.push(kv[1]);
+ }
+ if (!type) return;
+ if (!byType.has(type)) byType.set(type, new Set());
+ for (const k of keys) byType.get(type).add(k);
+ };
+ for (const { text } of files) {
+ for (const m of text.matchAll(/\.send\(\s*JSON\.stringify\(/g)) {
+ const args = callArgs(text, m.index + m[0].length - 1);
+ if (args && args[0]) add(args[0]);
+ }
+ for (const name of helpers) {
+ for (const m of text.matchAll(new RegExp(`(?<![\\w$.])${esc(name)}\\s*\\(\\s*(?=\\{)`, 'g'))) {
+ const args = callArgs(text, m.index + m[0].indexOf('('));
+ if (args && args[0]) add(args[0]);
+ }
+ }
+ }
+ return [...byType].map(([type, keys]) => ({ type, keys: [...keys].sort() })).sort((a, b) => (a.type < b.type ? -1 : 1));
+}
+
+/** The message types a client acts on: `case 'x':` and `.type === 'x'`. */
+function wsHandled(files) {
+ const types = new Set();
+ for (const { text } of files) {
+ for (const m of text.matchAll(/\bcase\s+['"]([\w:.-]+)['"]\s*:/g)) types.add(m[1]);
+ for (const m of text.matchAll(/\.type\s*[!=]==?\s*['"]([\w:.-]+)['"]/g)) types.add(m[1]);
+ }
+ return types;
+}
+
+/**
+ * One scripted socket session: connect to `url`, send each message in turn and wait until the server
+ * has been quiet for `quietMs`. → every message received (parsed JSON objects)
+ */
+async function wsSession({ url, headers = {}, messages, quietMs = 250, maxMs = 3000 }) {
+ const WebSocket = require('ws');
+ const ws = new WebSocket(url, { headers });
+ const got = [];
+ let last = Date.now();
+ ws.on('message', (d) => { try { const m = JSON.parse(String(d)); if (m && typeof m === 'object') got.push(m); } catch { /* not JSON */ } last = Date.now(); });
+ await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); ws.once('unexpected-response', (req, res) => reject(new Error(`upgrade refused: ${res.statusCode}`))); });
+ const settle = async () => {
+ const start = Date.now();
+ last = Date.now();
+ while (Date.now() - last < quietMs && Date.now() - start < maxMs) await new Promise((r) => setTimeout(r, 25));
+ };
+ await settle();
+ for (const m of messages) {
+ if (ws.readyState !== 1) break;
+ ws.send(JSON.stringify(m));
+ await settle();
+ }
+ await new Promise((resolve) => { ws.once('close', resolve); ws.close(); setTimeout(resolve, 500); });
+ return got;
+}
+
+/** The messages to send for a client's sends, in `order` (then the rest), with keys filled from `values`. */
+function wsMessages(sends, { order = [], values = {} } = {}) {
+ const rank = (t) => { const i = order.indexOf(t); return i < 0 ? order.length : i; };
+ return sends.slice().sort((a, b) => rank(a.type) - rank(b.type) || (a.type < b.type ? -1 : 1))
+ .map((s) => {
+ const m = { type: s.type };
+ for (const k of s.keys) if (values[k] !== undefined) m[k] = values[k];
+ return m;
+ });
+}
+
+/** Received messages grouped by type, for the types the client handles → { type: reads } */
+function wsReads(received, handled, ids) {
+ const byType = new Map();
+ for (const m of received) {
+ if (typeof m.type !== 'string' || !handled.has(m.type)) continue;
+ if (!byType.has(m.type)) byType.set(m.type, []);
+ byType.get(m.type).push(m);
+ }
+ const out = {};
+ for (const [t, list] of [...byType].sort((a, b) => (a[0] < b[0] ? -1 : 1))) out[t] = readsOf(list, ids);
+ return out;
+}
+
+/** What `recorded` ({ type: reads }) finds missing in the messages N sent. */
+function wsProblems(recorded, received) {
+ const problems = [];
+ for (const [type, reads] of Object.entries(recorded)) {
+ const list = received.filter((m) => m.type === type);
+ if (!list.length) { problems.push(`no '${type}' message any more`); continue; }
+ problems.push(...readProblems(reads, list).map((p) => `'${type}': ${p.replace(/^\[\]\.?/, '')}`));
+ }
+ return problems;
+}
+
+// ── SQL ──────────────────────────────────────────────────────
+
+const SQL_START = /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i;
+
+/** Every plain JS string literal in the files (a template only without ${}), comments skipped. */
+function stringLiterals(files) {
+ const out = [];
+ for (const { text } of files) {
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i];
+ if (c === '/' && text[i + 1] === '/') { const nl = text.indexOf('\n', i); i = nl < 0 ? text.length : nl; continue; }
+ if (c === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; continue; }
+ if (c !== '\'' && c !== '"' && c !== '`') continue;
+ const lit = readLiteral(text, i);
+ if (!lit) continue;
+ i = lit.end - 1;
+ if (lit.parts.length === 1 && lit.parts[0].text != null) out.push(lit.parts[0].text);
+ }
+ }
+ return out;
+}
+
+/** The literals that read like one SQL statement (DML). */
+function sqlLiterals(files) {
+ const out = new Set();
+ for (const t of stringLiterals(files)) {
+ const sql = normalizeSql(t);
+ if (SQL_START.test(sql) && /\b(FROM|INTO|SET|VALUES)\b/i.test(sql)) out.add(sql);
+ }
+ return [...out];
+}
+
+/** SQL without its comments (-- to the end of a line, and block comments), outside quoted text. */
+function stripSqlComments(sql) {
+ let out = '';
+ const s = String(sql);
+ for (let i = 0; i < s.length; i++) {
+ const c = s[i];
+ if (c === '\'' || c === '"') {
+ const j = s.indexOf(c, i + 1);
+ const end = j < 0 ? s.length : j + 1;
+ out += s.slice(i, end); i = end - 1; continue;
+ }
+ if (c === '-' && s[i + 1] === '-') { const nl = s.indexOf('\n', i); i = nl < 0 ? s.length : nl - 1; continue; }
+ if (c === '/' && s[i + 1] === '*') { const e = s.indexOf('*/', i + 2); i = e < 0 ? s.length : e + 1; out += ' '; continue; }
+ out += c;
+ }
+ return out;
+}
+
+function normalizeSql(sql) {
+ return stripSqlComments(sql).replace(/\s+/g, ' ').trim();
+}
+
+/**
+ * The DDL N-1 runs itself when it first needs a table or column (a module's CREATE TABLE IF NOT EXISTS,
+ * a boot-time ALTER TABLE … ADD COLUMN) that `db` does not have yet. Each statement is applied to `db`
+ * as it is found to apply; → the applied statements, tables first, in order.
+ */
+function lazyDDL(files, db) {
+ const tables = () => new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
+ const columns = (t) => { try { return new Set(db.prepare(`PRAGMA table_xinfo("${t}")`).all().map((c) => c.name.toLowerCase())); } catch { return new Set(); } };
+ const lits = stringLiterals(files).map(stripSqlComments);
+ const applied = [];
+ const apply = (ddl) => { try { db.exec(ddl); applied.push(ddl); return true; } catch { return false; } };
+ for (const text of lits) {
+ for (const m of text.matchAll(/CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+["`]?(\w+)["`]?\s*\(/gi)) {
+ // A rebuild's scratch table (x_new) is the migration's own business, not a table N-1 uses.
+ if (tables().has(m[1]) || /_(new|old|tmp|backup)$/i.test(m[1])) continue;
+ const end = skipBalanced(text, m.index + m[0].length - 1, '(', ')');
+ if (end >= 0) apply(normalizeSql(text.slice(m.index, end + 1)));
+ }
+ }
+ for (const text of lits) {
+ const m = /^\s*ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+(?:COLUMN\s+)?["`]?(\w+)["`]?[^;]*$/i.exec(text.trim().replace(/;$/, ''));
+ if (!m || !tables().has(m[1]) || columns(m[1]).has(m[2].toLowerCase())) continue;
+ apply(normalizeSql(text).replace(/;$/, ''));
+ }
+ return applied;
+}
+
+/** Every file under dir with one of the extensions, as { name (relative to root), text }. */
+function readTree(root, dirs, exts = ['.js']) {
+ const out = [];
+ const walk = (d) => {
+ let entries = [];
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
+ for (const e of entries) {
+ const p = path.join(d, e.name);
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && !e.name.startsWith('.')) walk(p); } else if (exts.some((x) => e.name.endsWith(x))) out.push({ name: path.relative(root, p), text: fs.readFileSync(p, 'utf8') });
+ }
+ };
+ for (const d of dirs) {
+ const abs = path.join(root, d);
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push({ name: d, text: fs.readFileSync(abs, 'utf8') });
+ else walk(abs);
+ }
+ return out.sort((a, b) => (a.name < b.name ? -1 : 1));
+}
+
+/** The schema as DDL, tables first (no internal or FTS shadow tables), then indexes, triggers and views. */
+function schemaDDL(db) {
+ const shadow = new Set(db.prepare('PRAGMA table_list').all().filter((t) => t.type === 'shadow').map((t) => t.name));
+ const rows = db.prepare("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'").all()
+ .filter((r) => !shadow.has(r.name));
+ const order = { table: 0, index: 1, view: 2, trigger: 3 };
+ return rows.sort((a, b) => order[a.type] - order[b.type] || (a.name < b.name ? -1 : 1)).map((r) => r.sql);
+}
+
+/** Prepares every statement on db (a missing table or column fails the prepare). → [{ sql, error }] */
+function prepareProblems(db, statements) {
+ const problems = [];
+ for (const sql of statements) {
+ try { db.prepare(sql); } catch (e) { problems.push({ sql, error: e.message }); }
+ }
+ return problems;
+}
+
+/**
+ * INSERTs of N-1 that N would refuse: a column N made NOT NULL without a default that the INSERT does
+ * not name. → [{ sql, error }]
+ */
+function insertProblems(db, statements) {
+ const problems = [];
+ const cols = new Map();
+ const required = (table) => {
+ if (!cols.has(table)) {
+ let info = [];
+ try { info = db.prepare(`PRAGMA table_xinfo("${table.replace(/"/g, '""')}")`).all(); } catch { info = []; }
+ const pkInt = info.filter((c) => c.pk).length === 1 && info.find((c) => c.pk && /^INTEGER$/i.test(c.type));
+ cols.set(table, info.filter((c) => c.notnull && c.dflt_value == null && !c.hidden && !(pkInt && c.pk)).map((c) => c.name));
+ }
+ return cols.get(table);
+ };
+ for (const sql of statements) {
+ const m = /^\s*(?:INSERT|REPLACE)(?:\s+OR\s+\w+)?\s+INTO\s+["`]?(\w+)["`]?\s*\(([^)]*)\)\s*(VALUES|SELECT)/i.exec(sql);
+ if (!m) continue;
+ const named = new Set(m[2].split(',').map((c) => c.trim().replace(/^["`]|["`]$/g, '').toLowerCase()));
+ const missing = required(m[1]).filter((c) => !named.has(c.toLowerCase()));
+ if (missing.length) problems.push({ sql, error: `${m[1]}.${missing.join(', ')} is NOT NULL without a default, and N-1 does not set it` });
+ }
+ return problems;
+}
+
+// ── Git ──────────────────────────────────────────────────────
+
+/** A detached worktree of `ref` in a temp directory sharing root's node_modules → { dir, sha, remove() }. */
+function worktree(root, ref) {
+ const sha = execFileSync('git', ['-C', root, 'rev-parse', '--verify', `${ref}^{commit}`], { encoding: 'utf8' }).trim();
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-'));
+ const dir = path.join(tmp, 'release');
+ execFileSync('git', ['-C', root, 'worktree', 'add', '--detach', dir, sha], { stdio: 'ignore' });
+ fs.symlinkSync(path.join(root, 'node_modules'), path.join(dir, 'node_modules'), 'dir');
+ return {
+ dir, sha,
+ remove() {
+ try { execFileSync('git', ['-C', root, 'worktree', 'remove', '--force', dir], { stdio: 'ignore' }); } catch { /* */ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ },
+ };
+}
+
+/** Files of another repository at a commit (git show), e.g. Live's chat widget for Chat. → [{ name, text }] */
+function gitFiles(repo, ref, dirs, exts = ['.js']) {
+ const names = execFileSync('git', ['-C', repo, 'ls-tree', '-r', '--name-only', ref, '--', ...dirs], { encoding: 'utf8', maxBuffer: 64 << 20 })
+ .split('\n').filter((n) => n && exts.some((x) => n.endsWith(x)) && !n.includes('node_modules/'));
+ return names.sort().map((n) => ({ name: `${path.basename(repo)}:${n}`, text: execFileSync('git', ['-C', repo, 'show', `${ref}:${n}`], { encoding: 'utf8', maxBuffer: 64 << 20 }) }));
+}
+
+// ── Reporting ────────────────────────────────────────────────
+
+function summarize(problems, max = 40) {
+ const lines = problems.slice(0, max);
+ if (problems.length > max) lines.push(`… and ${problems.length - max} more`);
+ return lines.join('\n');
+}
+
+module.exports = {
+ readLiteral, callArgs, splitTop, pathParts, extractCalls, concretePath, fillPath, unfillPath, pageLinks, clientIdentifiers,
+ flatten, readsOf, readProblems, statusCompatible,
+ send, requestsFor, notFoundProbe, isNoRoute, callProblems,
+ wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
+ stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
+ worktree, gitFiles, summarize,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+/**
+ * Loaded with `node -r` into a release booted by test/n-1/service.js (N-1 at record time, N in the
+ * test), from that release's directory. With N1_SQL_OUT set, every SQL text the process prepares or
+ * executes on the community database (COMMUNITY_DB_PATH, read when the statement runs) is written
+ * there as JSON when it exits.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const out = process.env.N1_SQL_OUT;
+if (out) {
+ const Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3'));
+ const seen = new Set();
+ const note = (db, sql) => {
+ try { if (process.env.COMMUNITY_DB_PATH && path.resolve(db.name) === path.resolve(process.env.COMMUNITY_DB_PATH)) seen.add(String(sql)); } catch { /* */ }
+ };
+ const prepare = Database.prototype.prepare;
+ Database.prototype.prepare = function (sql, ...rest) { note(this, sql); return prepare.call(this, sql, ...rest); };
+ const exec = Database.prototype.exec;
+ Database.prototype.exec = function (sql, ...rest) { note(this, sql); return exec.call(this, sql, ...rest); };
+ process.on('exit', () => { try { fs.writeFileSync(out, JSON.stringify([...seen])); } catch { /* */ } });
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+/**
+ * Community's side of the N-1 harness (test/n-1/harness.js): its clients, how a release boots and is
+ * seeded, where its SQL lives. Used by scripts/n-1-record.js (on N-1, in a temporary worktree) and by
+ * test/n-1.test.js (on this checkout), so both boot and seed the same way.
+ *
+ * Community's clients are its pages: public/js/community.js, the forms and scripts the templates in
+ * server/render write (an open tab posts the form it was served), the links, scripts and forms of the
+ * pages N-1 served (crawled), and the openvibe-sdk community client (pastes) as that release installed it.
+ *
+ * A release boots in a child process (test/n-1/boot-child.js) with its own Live, Network and Media
+ * mocks, the pastes authority on, and its database at COMMUNITY_DB_PATH; sign-in is the ov_token
+ * cookie the Network mock signs.
+ */
+const path = require('path');
+const { spawn } = require('child_process');
+const { readTree } = require('./harness');
+
+const PRELOAD = path.join(__dirname, 'preload.js');
+const CHILD = path.join(__dirname, 'boot-child.js');
+
+function baseEnv(extra) {
+ return { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR || '/tmp', NODE_ENV: 'test', ...extra };
+}
+
+module.exports = {
+ service: 'community',
+
+ clientFiles(dir) {
+ return readTree(dir, ['public/js', 'server/render', 'node_modules/openvibe-sdk/src/community.js'], ['.js']);
+ },
+ callers: [
+ { name: 'api' },
+ { name: 'fetch' },
+ { name: 'call', object: true },
+ ],
+ forms: true,
+ keep: () => true,
+ /** Pages an open tab may hold (anonymous): every link, script and form in them is replayed too. */
+ crawl: ['/', '/pastes', '/p/{@paste}', '/s/general', '/s/general/t/n-1-thread', '/c/{@comment_access}', '/search', '/pulse', '/new', '/my'],
+ origins: ['https://openvibe.community'],
+ /** Values for template expressions, first match wins; '@name' is a seeded row (boot-child.js ids). */
+ samples: [
+ [/space/i, 'general'],
+ [/thread\.slug/i, 'n-1-thread'],
+ [/access_?id/i, '@comment_access'],
+ [/comment_?id/i, '1'],
+ [/slug|data-copy-content|data-delete/i, '@paste'],
+ [/user_?name/i, 'n1star'],
+ [/id\)*$/i, '1'],
+ ],
+
+ sqlDirs: ['server'],
+ ledgerTables: [],
+
+ /** Community migrates when it opens its database (server/db.js openDb); nothing to do first. */
+ seed() {},
+
+ /** Boots the release in `dir` on dbPath → { url, ids, headers(auth), close() }. */
+ async boot({ dir, dbPath, sqlOut = '' }) {
+ const child = spawn(process.execPath, ['-r', PRELOAD, CHILD], {
+ cwd: dir,
+ env: baseEnv({ N1_DB: dbPath, N1_SQL_OUT: sqlOut }),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let log = '';
+ let info = null;
+ const ready = new Promise((resolve, reject) => {
+ child.stdout.on('data', (c) => {
+ log = (log + c).slice(-20000);
+ const line = String(c).split('\n').find((l) => l.startsWith('{"n1":'));
+ if (line && !info) { info = JSON.parse(line).n1; resolve(); }
+ });
+ child.on('exit', () => reject(new Error(`the release in ${dir} exited while booting:\n${log.slice(-3000)}`)));
+ });
+ child.stderr.on('data', (c) => { log = (log + c).slice(-20000); });
+ const exited = new Promise((resolve) => child.on('exit', resolve));
+ const timer = setTimeout(() => child.kill('SIGKILL'), 60000);
+ await ready;
+ clearTimeout(timer);
+ return {
+ url: info.url,
+ ids: info.ids,
+ log: () => log,
+ headers: (auth) => (auth === 'user' ? { cookie: `ov_token=${info.token}` } : {}),
+ async close() {
+ if (child.exitCode == null) child.kill('SIGTERM');
+ const t = setTimeout(() => { if (child.exitCode == null) child.kill('SIGKILL'); }, 8000);
+ await exited;
+ clearTimeout(t);
+ },
+ };
+ },
+};