openvibers/openvibe.media 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 a real server/index.js in the drill sandbox (MEDIA_DRILL: loopback, no outbound connection, program or background job; test/n-1/preload.js lets writes through) on a database created from the previous release's schema, so Media's migrations run over it, and replays test/fixtures/n-1/client.json inside openvibe-shared's mixed-version matrix (release-compat assertMixedVersion): 64 requests (the openvibe-sdk media and objects clients, anonymously and with an app key as the SDK signs in: files v1, Object API v2 and jobs; the object explorer; and every player URL, link, script and form of the /, /browse, /v, /c, /p, /me and /updates pages that release served), 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; a route that answers as an unknown path fails). Then every statement in test/fixtures/n-1/worker.json (331: what that release ran, plus its SQL literals, workers included) must still prepare on the migrated schema, and no old INSERT may miss a new NOT NULL column without a default. Fixtures recorded from b7fbe65, the release in production; refresh after each deploy with `npm run n-1:record [ref]` (a temporary worktree of the release, seeded, booted, replayed). The harness is the file Live, Chat and Community share (test/n-1/harness.js); test/n-1/service.js is Media's part. In npm test, so in CI, no git history or network. Checked by breaking it: renaming /v/:id/transcript.json and /p/:slug/raw 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 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:@@@ -346,6 +346,17 @@ backup and starts a second Media from this checkout on 127.0.0.1:14100 with `MED`test/drill-mode.test.js` boots the real server that way against a copy whose app has a webhook URLand checks each point.++## N-1: the previous release against this one++++`test/n-1.test.js` (roadmap WS-P task 11, in `npm test`) boots this checkout in the drill sandbox++(with writes let through) on a database the previous release created, so this release's migrations++run over it, and replays `test/fixtures/n-1/`: every call the previous release's clients make (the++openvibe-sdk media and objects clients with an app key, the object explorer, and every player URL,++link, script and form of the `/`, `/browse`, `/v`, `/c`, `/p`, `/me` and `/updates` pages it served),++each answered compatibly (status, JSON, the response fields the client reads). Then every SQL statement++the previous release runs must still prepare on the migrated schema. After each deploy, record the++release now in production as the next N-1 (`npm run n-1:record [ref]`) and commit the fixtures.++## Client addressesExpress `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that the@@@ -7,7 +7,8 @@"start": "node server/index.js","init-db": "node server/db/init.js","smoke": "bash scripts/smoke-test.sh",-- "test": "for f in test/*.test.js; do node \"$f\" || exit 1; done"++ "test": "for f in test/*.test.js; do node \"$f\" || exit 1; done",++ "n-1:record": "node scripts/n-1-record.js"},"license": "MIT","dependencies": {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,836 @@++{++ "service": "media",++ "release": "b7fbe65029f38fd3ec6b10919eab9b8ba13df9d4",++ "recorded_at": "2026-09-26T14:14:01.055Z",++ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",++ "manifest": {++ "service": "media",++ "release": "b7fbe65029f3",++ "released_at": "2026-09-26T13:16:26.000Z",++ "booted_at": "2026-09-26T14:14:00.188Z",++ "contracts_version": "0.54.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": "b7fbe65029f3"++ },++ "server": {++ "kind": "server",++ "version": "b7fbe65029f3"++ }++ },++ "assets": {},++ "schema_generation": null,++ "schema_compatible_from": null,++ "contract_ranges": {},++ "metrics_url": "/release-metrics"++ },++ "dynamic_calls": 3,++ "calls": [++ {++ "method": "GET",++ "path": "/api/v1/live/files",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/media.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v1/live/files",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "files": "array",++ "files[]": "object",++ "files[].key": "string",++ "files[].app_id": "string",++ "files[].user_id": "null",++ "files[].original_name": "string",++ "files[].size": "number",++ "files[].mime": "string",++ "files[].sha256": "null",++ "files[].url": "string",++ "files[].created_at": "string",++ "used_bytes": "number",++ "limit": "number",++ "offset": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/media.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v1/live/files/n1file.txt",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/media.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v1/live/files/n1file.txt",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "key": "string",++ "app_id": "string",++ "user_id": "null",++ "original_name": "string",++ "size": "number",++ "mime": "string",++ "sha256": "null",++ "url": "string",++ "created_at": "string"++ },++ "from": [++ "node_modules/openvibe-sdk/src/media.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/me/objects/1",++ "auth": "anon",++ "status": 503,++ "kind": "json",++ "from": [++ "server/me/client.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/me/objects/1",++ "auth": "user",++ "status": 503,++ "kind": "json",++ "from": [++ "server/me/client.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/me/objects?status=uploading&limit=100",++ "auth": "anon",++ "status": 503,++ "kind": "json",++ "from": [++ "server/me/client.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/me/objects?status=uploading&limit=100",++ "auth": "user",++ "status": 503,++ "kind": "json",++ "from": [++ "server/me/client.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/me/objects",++ "auth": "anon",++ "status": 503,++ "kind": "json",++ "from": [++ "server/me/client.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/me/objects",++ "auth": "user",++ "status": 503,++ "kind": "json",++ "from": [++ "server/me/client.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/jobs",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/jobs",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "jobs": "array",++ "next_cursor": "null",++ "limit": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/jobs/{@object}",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/jobs/{@object}",++ "auth": "user",++ "status": 404,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/objects",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/objects",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "objects": "array",++ "objects[]": "object",++ "objects[].id": "string",++ "objects[].legacy_ref": "string",++ "objects[].app_id": "string",++ "objects[].namespace": "string",++ "objects[].kind": "string",++ "objects[].owner": "object",++ "objects[].owner.subject": "null",++ "objects[].owner.app": "string",++ "objects[].owner.user_id": "number",++ "objects[].visibility": "string",++ "objects[].lifecycle_status": "string",++ "objects[].mime_type": "string",++ "objects[].size_bytes": "number",++ "objects[].content_hash": "null",++ "objects[].metadata": "object",++ "objects[].metadata.title": "string",++ "objects[].metadata.duration_seconds": "number",++ "objects[].metadata.health_status": "string",++ "objects[].metadata.managed_stream_id": "null",++ "objects[].metadata.recording": "boolean",++ "objects[].held": "boolean",++ "objects[].readiness": "object",++ "objects[].readiness.metadata": "boolean",++ "objects[].readiness.bytes_verified": "boolean",++ "objects[].readiness.playable": "boolean",++ "objects[].readiness.reason": "string",++ "objects[].public_url": "string",++ "objects[].created_at": "string",++ "objects[].updated_at": "string",++ "objects[].deleted_at": "null",++ "objects[].metadata.auto_generated": "boolean",++ "objects[].metadata.filename": "string",++ "next_cursor": "null",++ "limit": "number"++ },++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/objects/{@object}",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/objects/{@object}",++ "auth": "user",++ "status": 200,++ "kind": "json",++ "reads": {++ "id": "string",++ "legacy_ref": "string",++ "app_id": "string",++ "namespace": "string",++ "kind": "string",++ "owner": "object",++ "owner.subject": "null",++ "owner.app": "string",++ "owner.user_id": "number",++ "visibility": "string",++ "lifecycle_status": "string",++ "mime_type": "string",++ "size_bytes": "number",++ "content_hash": "null",++ "metadata": "object",++ "metadata.title": "string",++ "metadata.duration_seconds": "number",++ "metadata.health_status": "string",++ "metadata.managed_stream_id": "null",++ "metadata.recording": "boolean",++ "held": "boolean",++ "readiness": "object",++ "readiness.metadata": "boolean",++ "readiness.bytes_verified": "boolean",++ "readiness.playable": "boolean",++ "readiness.reason": "null",++ "public_url": "string",++ "created_at": "string",++ "updated_at": "string",++ "deleted_at": "null",++ "locations": "array",++ "locations[]": "object",++ "locations[].provider": "string",++ "locations[].storage_class": "string",++ "locations[].state": "string",++ "locations[].size_bytes": "number",++ "locations[].verified_at": "string",++ "locations[].canonical": "boolean"++ },++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/objects/{@object}/download",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/objects/{@object}/download",++ "auth": "user",++ "status": 302,++ "kind": "empty",++ "location": "/v/1",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/objects/{@object}/multipart/n1-upload",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/api/v2/live/objects/{@object}/multipart/n1-upload",++ "auth": "user",++ "status": 404,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "GET",++ "path": "/me",++ "auth": "anon",++ "status": 503,++ "kind": "html",++ "from": [++ "server/me/pages.js"++ ]++ },++ {++ "method": "GET",++ "path": "/me",++ "auth": "user",++ "status": 503,++ "kind": "html",++ "from": [++ "server/me/pages.js"++ ]++ },++ {++ "method": "GET",++ "path": "/me/ops",++ "auth": "anon",++ "status": 503,++ "kind": "html",++ "from": [++ "server/me/pages.js"++ ]++ },++ {++ "method": "GET",++ "path": "/me/ops",++ "auth": "user",++ "status": 503,++ "kind": "html",++ "from": [++ "server/me/pages.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",++ "scope": "string"++ },++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/?tab=all",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/?tab=videos",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/?tab=clips",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/?tab=images",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/?tab=text",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/?tab=emotes",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/?tab=sounds",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/?tab=thumbnails",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/?tab=files",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/v/1",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/t/vod-1-1.jpg",++ "auth": "anon",++ "accept": "html",++ "status": 503,++ "kind": "json",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/c/1",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page /"++ ]++ },++ {++ "method": "GET",++ "path": "/p/n1paste",++ "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": "/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": "/browse",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "html",++ "from": [++ "page"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/theme-loader.js?v=d9b9af081efe",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /v/1"++ ]++ },++ {++ "method": "GET",++ "path": "/v/1?raw=1",++ "auth": "anon",++ "accept": "html",++ "status": 503,++ "kind": "json",++ "from": [++ "page /v/1"++ ]++ },++ {++ "method": "GET",++ "path": "/v/1/transcript.json",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "json",++ "reads": {++ "vod_id": "number",++ "title": "string",++ "created_at": "string",++ "duration_seconds": "number",++ "visibility": "string",++ "is_recording": "boolean",++ "managed_stream_id": "null",++ "ai_overview": "null",++ "ai_analyzed_at": "null",++ "transcript": "null",++ "segments": "array",++ "sound_events": "array",++ "app_id": "string",++ "generated_at": "string"++ },++ "from": [++ "page /v/1"++ ]++ },++ {++ "method": "GET",++ "path": "/c/1?raw=1",++ "auth": "anon",++ "accept": "html",++ "status": 503,++ "kind": "json",++ "from": [++ "page /c/1"++ ]++ },++ {++ "method": "GET",++ "path": "/p/n1paste/raw",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "text",++ "from": [++ "page /p/n1paste"++ ]++ },++ {++ "method": "GET",++ "path": "/shared/shipped.js?v=18e4e7ae4ec3",++ "auth": "anon",++ "accept": "html",++ "status": 200,++ "kind": "other",++ "from": [++ "page /updates"++ ]++ },++ {++ "method": "POST",++ "path": "/api/v1/live/files",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/media.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/v1/live/files/n1file.txt",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/media.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/v2/live/jobs",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/v2/live/jobs/{@object}/approve",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/v2/live/jobs/{@object}/cancel",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/v2/live/objects",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/v2/live/objects/{@object}/complete",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "DELETE",++ "path": "/api/v2/live/objects/{@object}",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "POST",++ "path": "/api/v2/live/objects/{@object}/multipart/n1-upload/complete",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "PUT",++ "path": "/api/v2/live/objects/{@object}/multipart/n1-upload/parts/1",++ "auth": "anon",++ "status": 401,++ "kind": "json",++ "from": [++ "node_modules/openvibe-sdk/src/objects.js"++ ]++ },++ {++ "method": "POST",++ "path": "/me/ops/recompute",++ "auth": "anon",++ "status": 503,++ "kind": "html",++ "from": [++ "server/me/pages.js"++ ]++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,423 @@++{++ "service": "media",++ "release": "b7fbe65029f38fd3ec6b10919eab9b8ba13df9d4",++ "recorded_at": "2026-09-26T14:14:01.055Z",++ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",++ "user_version": 0,++ "schema": [++ "CREATE TABLE apps (\n app_id TEXT PRIMARY KEY,\n name TEXT NOT NULL DEFAULT '',\n api_key_hash TEXT NOT NULL,\n webhook_url TEXT,\n webhook_secret TEXT,\n allowed_origins TEXT DEFAULT '[]', -- JSON array of origins for browser (user-JWT) calls\n quota_bytes INTEGER DEFAULT 0, -- files quota; 0 = unlimited\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n, project_id TEXT, env TEXT)",++ "CREATE TABLE assets (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n app_id TEXT NOT NULL DEFAULT 'live',\n kind TEXT NOT NULL, -- 'emote' | 'sound'\n name TEXT NOT NULL, -- emote code or !command\n file_path TEXT NOT NULL,\n mime TEXT DEFAULT 'application/octet-stream',\n user_id INTEGER, -- uploader (app-local id)\n username TEXT DEFAULT '', -- uploader name snapshot\n channel_username TEXT DEFAULT '', -- channel it belongs to\n duration_seconds REAL DEFAULT 0,\n meta_json TEXT DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE clips (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n app_id TEXT NOT NULL DEFAULT 'live',\n vod_id INTEGER,\n stream_id INTEGER,\n user_id INTEGER,\n channel_user_id INTEGER, -- owner of the clipped channel/stream (list filter)\n title TEXT DEFAULT 'Untitled Clip',\n description TEXT DEFAULT '',\n file_path TEXT,\n thumbnail_url TEXT,\n start_time REAL NOT NULL DEFAULT 0,\n end_time REAL NOT NULL DEFAULT 0,\n duration_seconds REAL DEFAULT 0,\n is_public INTEGER DEFAULT 1,\n visibility TEXT DEFAULT 'public',\n status TEXT DEFAULT 'ready', -- processing | ready | failed (imports default ready)\n view_count INTEGER DEFAULT 0,\n auto_generated INTEGER DEFAULT 0,\n storage_provider TEXT DEFAULT 'local',\n storage_key TEXT,\n ai_overview TEXT,\n ai_transcript TEXT,\n ai_analyzed_at DATETIME,\n object_id TEXT, -- media_objects.id\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n, cut_error TEXT, cut_attempts INTEGER DEFAULT 0, cut_next_at DATETIME, unique_views INTEGER DEFAULT 0)",++ "CREATE TABLE content_views (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n content_type TEXT NOT NULL CHECK(content_type IN ('vod', 'clip')),\n content_id INTEGER NOT NULL,\n ip TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(content_type, content_id, ip)\n)",++ "CREATE TABLE content_visits (\n content_type TEXT NOT NULL, -- vod | clip | paste | file\n content_id INTEGER NOT NULL,\n visitor TEXT NOT NULL, -- 'u:<userId>' or 'ip:<hmac>'\n first_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n visits INTEGER DEFAULT 1,\n PRIMARY KEY (content_type, content_id, visitor)\n )",++ "CREATE TABLE files (\n key TEXT PRIMARY KEY,\n app_id TEXT NOT NULL DEFAULT 'live',\n user_id INTEGER,\n original_name TEXT,\n size INTEGER NOT NULL DEFAULT 0,\n mime TEXT DEFAULT 'application/octet-stream',\n sha256 TEXT,\n object_id TEXT, -- media_objects.id\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n, unique_views INTEGER DEFAULT 0, view_count INTEGER DEFAULT 0)",++ "CREATE TABLE media_holds (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL,\n kind TEXT NOT NULL CHECK(kind IN ('moderation', 'dmca', 'creator_pin', 'admin', 'evidence')),\n reason TEXT NOT NULL DEFAULT '',\n created_by TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n released_at DATETIME,\n released_by TEXT,\n note TEXT\n)",++ "CREATE TABLE media_invariant_violations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL UNIQUE,\n level TEXT NOT NULL CHECK(level IN ('warn', 'violation')),\n size_bytes INTEGER NOT NULL,\n threshold_bytes INTEGER NOT NULL,\n detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n resolved_at DATETIME\n)",++ "CREATE TABLE media_jobs (\n id TEXT PRIMARY KEY, -- mjob_<ULID>\n app_id TEXT NOT NULL, -- the tenant that owns the job\n object_id TEXT, -- the object it works on (NULL for tenant-wide jobs: invariant.scan)\n job_type TEXT NOT NULL, -- thumbnail.regenerate | invariant.scan | object.split | object.remux\n status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('proposed', 'queued', 'running', 'succeeded', 'failed', 'cancelled')),\n idempotency_key TEXT, -- unique per tenant: a repeat answers with the same job\n request_hash TEXT, -- sha256 of { type, object_id, params }: a different request under a used key is refused\n params TEXT NOT NULL DEFAULT '{}',\n result TEXT,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n run_after DATETIME, -- queued: not before (retry backoff)\n lease_until DATETIME, -- running: renewed while the worker holds the job\n lease_token TEXT, -- running: the claim's random fencing token; renew/checkpoint/succeed/fail must match it\n checkpoint TEXT, -- handler progress (JSON); a retry resumes from it\n error TEXT,\n error_code TEXT, -- stable code of the last failure (media_unavailable, quota_exceeded, …)\n cancel_requested INTEGER NOT NULL DEFAULT 0,\n created_by TEXT, -- svc:<id> | app:<app> | app:<app>:user:<id> | system:<what>\n owner_user_id INTEGER, -- the app's user it was created for (X-OV-User-Id), if any\n decided_by TEXT, -- who approved or rejected a proposal\n decided_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n started_at DATETIME,\n finished_at DATETIME\n)",++ "CREATE TABLE media_locations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL,\n provider TEXT NOT NULL CHECK(provider IN ('local', 'b2', 'r2')),\n bucket TEXT,\n key TEXT NOT NULL, -- absolute path for local, object key for b2/r2\n storage_class TEXT, -- hot (local) | cold (b2 canonical) | cache (r2)\n state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('present', 'missing', 'pending', 'corrupt')),\n checksum TEXT,\n size_bytes INTEGER,\n verified_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(object_id, provider)\n)",++ "CREATE TABLE media_namespaces (\n namespace TEXT PRIMARY KEY,\n app_id TEXT NOT NULL, -- the tenant it belongs to (and whose quota it counts against)\n parent TEXT, -- NULL for a root\n owner TEXT NOT NULL, -- service:<app_id> (a first-party tenant) | project:<prj_…> (a developer project)\n policy TEXT NOT NULL DEFAULT '{}', -- JSON: kinds, visibilities, max_object_bytes, strict_verbs (children inherit)\n quota_bytes INTEGER,\n quota_objects INTEGER,\n used_bytes INTEGER NOT NULL DEFAULT 0,\n used_objects INTEGER NOT NULL DEFAULT 0,\n reserved_bytes INTEGER NOT NULL DEFAULT 0,\n reserved_objects INTEGER NOT NULL DEFAULT 0,\n reconciled_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE media_object_changes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL,\n change TEXT NOT NULL CHECK(change IN ('deleted', 'visibility_changed')),\n previous_visibility TEXT,\n visibility TEXT,\n changed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))\n)",++ "CREATE TABLE media_objects (\n id TEXT PRIMARY KEY, -- med_<ULID> (time-sortable)\n app_id TEXT NOT NULL, -- tenant\n namespace TEXT NOT NULL, -- a media_namespaces row: the tenant's root (app id, or app.<project_id>[.sandbox]) or a child of it\n kind TEXT NOT NULL CHECK(kind IN ('vod', 'clip', 'file', 'thumbnail', 'screenshot', 'avatar', 'asset')),\n owner_subject TEXT, -- canonical subject (usr_<ULID>) when known\n owner_app TEXT, -- legacy owner: the app whose user-id space owner_user_id is in\n owner_user_id INTEGER,\n visibility TEXT NOT NULL DEFAULT 'private' CHECK(visibility IN ('public', 'unlisted', 'private')),\n lifecycle_status TEXT NOT NULL DEFAULT 'uploading' CHECK(lifecycle_status IN ('uploading', 'ready', 'failed', 'archived', 'deleted')),\n mime_type TEXT,\n size_bytes INTEGER NOT NULL DEFAULT 0,\n content_hash TEXT, -- sha256 hex\n canonical_provider TEXT, -- local | b2 | r2\n canonical_key TEXT,\n legacy_ref TEXT UNIQUE, -- legacy:<app>:<kind>:<id> for projected rows; NULL = native v2 object\n metadata TEXT NOT NULL DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME\n)",++ "CREATE TABLE media_quota_reservations (\n object_id TEXT PRIMARY KEY,\n app_id TEXT NOT NULL,\n namespace TEXT NOT NULL,\n bytes INTEGER NOT NULL DEFAULT 0,\n expires_at DATETIME NOT NULL, -- the expiry sweep fails the upload after this (unless a multipart session is open)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE media_relationships (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n from_object_id TEXT NOT NULL,\n relation TEXT NOT NULL, -- clip_of | thumbnail_of | derived_from | screenshot_of\n to_object_id TEXT NOT NULL,\n metadata TEXT NOT NULL DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(from_object_id, relation, to_object_id)\n)",++ "CREATE TABLE media_settings (\n key TEXT PRIMARY KEY,\n value TEXT,\n description TEXT DEFAULT '',\n type TEXT DEFAULT 'string',\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",++ "CREATE TABLE media_tier_decisions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n decided_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),\n vod_id INTEGER NOT NULL,\n app_id TEXT,\n object_id TEXT,\n action TEXT NOT NULL CHECK(action IN ('promote', 'demote')),\n from_provider TEXT,\n to_provider TEXT,\n outcome TEXT NOT NULL CHECK(outcome IN ('done', 'already', 'refused', 'failed')),\n trigger TEXT NOT NULL, -- sweep | admin | drill | clip | manual\n reason TEXT NOT NULL,\n inputs TEXT NOT NULL DEFAULT '{}', -- JSON: view_count, last_accessed_at, storage_provider, is_recording, held, file_size, created_at\n thresholds TEXT NOT NULL DEFAULT '{}',-- JSON: the R2 policy in force, each value with its source (default | setting)\n error TEXT\n)",++ "CREATE TABLE media_upload_parts (\n upload_id TEXT NOT NULL,\n part_number INTEGER NOT NULL, -- 1-based\n size_bytes INTEGER NOT NULL,\n sha256 TEXT NOT NULL,\n received_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (upload_id, part_number)\n)",++ "CREATE TABLE media_uploads (\n id TEXT PRIMARY KEY, -- mup_<ULID>\n object_id TEXT NOT NULL,\n app_id TEXT NOT NULL,\n part_size INTEGER NOT NULL,\n total_size INTEGER NOT NULL,\n parts_expected INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completing', 'completed', 'aborted', 'expired')),\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n expires_at DATETIME NOT NULL,\n completed_at DATETIME\n)",++ "CREATE TABLE media_variants (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL, -- the source object\n variant_name TEXT NOT NULL, -- thumbnail | 720p | waveform | …\n derived_object_id TEXT NOT NULL,\n recipe TEXT, -- how it was made (name@version)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(object_id, variant_name)\n)",++ "CREATE TABLE media_verifications (\n object_id TEXT PRIMARY KEY,\n verified_at TEXT NOT NULL, -- ISO-8601 with ms (orders the rotation)\n status TEXT NOT NULL CHECK(status IN ('good', 'no_good_copy', 'unverifiable')),\n good_providers TEXT, -- comma list of providers holding a good copy\n detail TEXT NOT NULL DEFAULT '{}', -- per-location verdicts and any re-upload\n run_id INTEGER\n)",++ "CREATE TABLE media_verify_runs (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n objects_checked INTEGER NOT NULL DEFAULT 0,\n locations_checked INTEGER NOT NULL DEFAULT 0,\n good INTEGER NOT NULL DEFAULT 0,\n no_good_copy INTEGER NOT NULL DEFAULT 0,\n unverifiable INTEGER NOT NULL DEFAULT 0,\n reuploaded INTEGER NOT NULL DEFAULT 0,\n reupload_failed INTEGER NOT NULL DEFAULT 0,\n no_good_copy_total INTEGER, -- ready objects with no good copy anywhere, after this run\n error TEXT\n)",++ "CREATE TABLE paste_comments (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n paste_id INTEGER NOT NULL,\n user_id INTEGER,\n parent_id INTEGER,\n anon_name TEXT,\n message TEXT NOT NULL,\n ip_address TEXT,\n is_deleted INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE,\n FOREIGN KEY (parent_id) REFERENCES paste_comments(id) ON DELETE CASCADE\n)",++ "CREATE TABLE paste_likes (\n paste_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (paste_id, user_id),\n FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE\n)",++ "CREATE TABLE pastes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n app_id TEXT NOT NULL DEFAULT 'live',\n slug TEXT UNIQUE NOT NULL,\n user_id INTEGER,\n type TEXT 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 DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),\n stream_id INTEGER,\n screenshot_path TEXT,\n metadata TEXT,\n burn_after_read INTEGER DEFAULT 0,\n forked_from INTEGER,\n pinned INTEGER DEFAULT 0,\n views INTEGER DEFAULT 0,\n copies INTEGER DEFAULT 0,\n likes INTEGER DEFAULT 0,\n is_nsfw INTEGER DEFAULT 0,\n ip_address TEXT,\n ai_summary TEXT, -- kept for import; Live owns AI generation\n ai_tags TEXT,\n ai_analyzed_at DATETIME,\n object_id TEXT, -- media_objects.id of the screenshot/avatar bytes (text pastes have none)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, unique_views INTEGER DEFAULT 0,\n FOREIGN KEY (forked_from) REFERENCES pastes(id) ON DELETE SET NULL\n)",++ "CREATE TABLE vods (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n app_id TEXT NOT NULL DEFAULT 'live',\n stream_id INTEGER,\n stream_key TEXT,\n managed_stream_id INTEGER, -- owning app's stream \"slot\" (list filter)\n user_id INTEGER,\n title TEXT,\n description TEXT DEFAULT '',\n file_path TEXT,\n thumbnail_url TEXT,\n master_file_path TEXT,\n file_size INTEGER DEFAULT 0,\n duration_seconds INTEGER DEFAULT 0,\n probe_duration_seconds REAL DEFAULT 0,\n duration_source TEXT, -- probe | remux | unknown: where duration_seconds came from (NULL: before it was recorded)\n probe_format_json TEXT DEFAULT '',\n health_status TEXT DEFAULT 'unknown',\n health_score INTEGER DEFAULT 0,\n health_issues_json TEXT DEFAULT '[]',\n last_health_scan_at DATETIME,\n quarantined_at DATETIME,\n is_public INTEGER DEFAULT 1,\n visibility TEXT DEFAULT 'public',\n is_recording INTEGER DEFAULT 0,\n clips_only INTEGER DEFAULT 0,\n view_count INTEGER DEFAULT 0,\n storage_tier TEXT DEFAULT 'hot', -- legacy column (pre object-store); kept for import\n storage_provider TEXT DEFAULT 'local',\n storage_key TEXT,\n last_accessed_at DATETIME,\n ai_overview TEXT, -- kept for import; Live owns AI generation\n ai_transcript TEXT,\n ai_analyzed_at DATETIME,\n meta_json TEXT DEFAULT '{}',\n object_id TEXT, -- media_objects.id (Wave 4 object model)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n, unique_views INTEGER DEFAULT 0)",++ "CREATE INDEX idx_assets_app_kind ON assets(app_id, kind, created_at DESC)",++ "CREATE UNIQUE INDEX idx_assets_identity ON assets(app_id, kind, name, channel_username)",++ "CREATE INDEX idx_clips_app ON clips(app_id, created_at DESC)",++ "CREATE INDEX idx_clips_object ON clips(object_id)",++ "CREATE INDEX idx_clips_stream ON clips(stream_id)",++ "CREATE INDEX idx_clips_vod ON clips(vod_id)",++ "CREATE INDEX idx_content_views_lookup ON content_views(content_type, content_id)",++ "CREATE INDEX idx_content_visits_item ON content_visits(content_type, content_id)",++ "CREATE INDEX idx_content_visits_last ON content_visits(last_at)",++ "CREATE INDEX idx_files_app ON files(app_id, created_at DESC)",++ "CREATE INDEX idx_media_holds_object ON media_holds(object_id, released_at)",++ "CREATE INDEX idx_media_jobs_app ON media_jobs(app_id, id)",++ "CREATE UNIQUE INDEX idx_media_jobs_idem ON media_jobs(app_id, idempotency_key) WHERE idempotency_key IS NOT NULL",++ "CREATE INDEX idx_media_jobs_object ON media_jobs(object_id, job_type, status)",++ "CREATE INDEX idx_media_jobs_status ON media_jobs(status, job_type)",++ "CREATE INDEX idx_media_namespaces_app ON media_namespaces(app_id, namespace)",++ "CREATE INDEX idx_media_objects_app ON media_objects(app_id, id)",++ "CREATE INDEX idx_media_objects_kind ON media_objects(app_id, kind)",++ "CREATE INDEX idx_media_objects_lifecycle ON media_objects(lifecycle_status)",++ "CREATE INDEX idx_media_objects_namespace ON media_objects(app_id, namespace, lifecycle_status)",++ "CREATE INDEX idx_media_objects_owner ON media_objects(owner_subject)",++ "CREATE INDEX idx_media_quota_reservations_expiry ON media_quota_reservations(expires_at)",++ "CREATE INDEX idx_media_quota_reservations_ns ON media_quota_reservations(app_id, namespace)",++ "CREATE INDEX idx_media_relationships_to ON media_relationships(to_object_id, relation)",++ "CREATE INDEX idx_media_tier_decisions_app ON media_tier_decisions(app_id, id)",++ "CREATE INDEX idx_media_tier_decisions_vod ON media_tier_decisions(vod_id, id)",++ "CREATE INDEX idx_media_uploads_expiry ON media_uploads(status, expires_at)",++ "CREATE INDEX idx_media_uploads_object ON media_uploads(object_id, status)",++ "CREATE INDEX idx_media_verifications_at ON media_verifications(verified_at)",++ "CREATE INDEX idx_media_verifications_status ON media_verifications(status)",++ "CREATE INDEX idx_paste_comments_ip ON paste_comments(ip_address)",++ "CREATE INDEX idx_paste_comments_paste ON paste_comments(paste_id)",++ "CREATE INDEX idx_pastes_app ON pastes(app_id, created_at DESC)",++ "CREATE INDEX idx_pastes_slug ON pastes(slug)",++ "CREATE INDEX idx_pastes_user ON pastes(user_id)",++ "CREATE INDEX idx_pastes_visibility ON pastes(visibility)",++ "CREATE INDEX idx_vods_app ON vods(app_id, created_at DESC)",++ "CREATE INDEX idx_vods_provider ON vods(storage_provider)",++ "CREATE INDEX idx_vods_recording ON vods(is_recording)",++ "CREATE TRIGGER trg_clips_hold_guard_v2 BEFORE DELETE ON clips\n WHEN (OLD.object_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.object_id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.object_id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.object_id)))) OR (OLD.vod_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h JOIN vods v ON v.object_id = h.object_id WHERE v.id = OLD.vod_id AND h.released_at IS NULL))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",++ "CREATE TRIGGER trg_clips_object_deleted AFTER DELETE ON clips\n WHEN OLD.object_id IS NOT NULL\n BEGIN\n UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP),\n updated_at = CURRENT_TIMESTAMP\n WHERE id = OLD.object_id AND lifecycle_status != 'deleted';\n END",++ "CREATE TRIGGER trg_files_hold_guard_v2 BEFORE DELETE ON files\n WHEN (OLD.object_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.object_id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.object_id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.object_id))))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",++ "CREATE TRIGGER trg_files_object_deleted AFTER DELETE ON files\n WHEN OLD.object_id IS NOT NULL\n BEGIN\n UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP),\n updated_at = CURRENT_TIMESTAMP\n WHERE id = OLD.object_id AND lifecycle_status != 'deleted';\n END",++ "CREATE TRIGGER trg_media_objects_deleted_event AFTER UPDATE OF lifecycle_status ON media_objects\n WHEN NEW.lifecycle_status = 'deleted' AND OLD.lifecycle_status != 'deleted'\n BEGIN INSERT INTO media_object_changes (object_id, change, previous_visibility) VALUES (NEW.id, 'deleted', OLD.visibility); END",++ "CREATE TRIGGER trg_media_objects_hold_delete_v2 BEFORE DELETE ON media_objects\n WHEN EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.id)))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",++ "CREATE TRIGGER trg_media_objects_hold_guard_v2 BEFORE UPDATE OF lifecycle_status ON media_objects\n WHEN NEW.lifecycle_status = 'deleted' AND OLD.lifecycle_status != 'deleted' AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.id)))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",++ "CREATE TRIGGER trg_media_objects_visibility_event AFTER UPDATE OF visibility ON media_objects\n WHEN OLD.visibility IS NOT NEW.visibility AND NEW.lifecycle_status != 'deleted'\n BEGIN INSERT INTO media_object_changes (object_id, change, previous_visibility, visibility) VALUES (NEW.id, 'visibility_changed', OLD.visibility, NEW.visibility); END",++ "CREATE TRIGGER trg_pastes_hold_guard_v2 BEFORE DELETE ON pastes\n WHEN (OLD.object_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.object_id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.object_id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.object_id))))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",++ "CREATE TRIGGER trg_pastes_object_deleted AFTER DELETE ON pastes\n WHEN OLD.object_id IS NOT NULL\n BEGIN\n UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP),\n updated_at = CURRENT_TIMESTAMP\n WHERE id = OLD.object_id AND lifecycle_status != 'deleted';\n END",++ "CREATE TRIGGER trg_vods_hold_guard_v2 BEFORE DELETE ON vods\n WHEN (OLD.object_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.object_id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.object_id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.object_id))))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",++ "CREATE TRIGGER trg_vods_object_deleted AFTER DELETE ON vods\n WHEN OLD.object_id IS NOT NULL\n BEGIN\n UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP),\n updated_at = CURRENT_TIMESTAMP\n WHERE id = OLD.object_id AND lifecycle_status != 'deleted';\n END"++ ],++ "ledger": {},++ "lazy": [++ "CREATE TABLE IF NOT EXISTS search_doc_pushes ( kind TEXT NOT NULL, media_id INTEGER NOT NULL, hash TEXT NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (kind, media_id) )"++ ],++ "statements": [++ "DELETE FROM assets WHERE id = ?",++ "DELETE FROM clips WHERE id = ?",++ "DELETE FROM content_views WHERE content_type = 'vod' AND content_id = ?",++ "DELETE FROM content_visits WHERE last_at < datetime('now', ?)",++ "DELETE FROM files WHERE key = ?",++ "DELETE FROM media_locations WHERE id = ?",++ "DELETE FROM media_object_changes",++ "DELETE FROM media_object_changes WHERE id = ?",++ "DELETE FROM media_objects WHERE id = ?",++ "DELETE FROM media_quota_reservations WHERE object_id = ?",++ "DELETE FROM media_settings WHERE key = ?",++ "DELETE FROM media_upload_parts WHERE upload_id = ?",++ "DELETE FROM paste_likes WHERE paste_id = ? AND user_id = ?",++ "DELETE FROM pastes WHERE id = ?",++ "DELETE FROM vods WHERE id = ?",++ "INSERT INTO apps (app_id, name, api_key_hash, quota_bytes) VALUES (?, ?, '', ?)",++ "INSERT INTO apps (app_id, name, api_key_hash, webhook_url, webhook_secret, allowed_origins, quota_bytes) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(app_id) DO UPDATE SET name = excluded.name, api_key_hash = excluded.api_key_hash, webhook_url = excluded.webhook_url, webhook_secret = excluded.webhook_secret, allowed_origins = excluded.allowed_origins, quota_bytes = excluded.quota_bytes",++ "INSERT INTO assets (app_id, kind, name, file_path, mime, user_id, username, channel_username, duration_seconds, meta_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(app_id, kind, name, channel_username) DO UPDATE SET file_path = excluded.file_path, mime = excluded.mime, user_id = excluded.user_id, username = excluded.username, duration_seconds = excluded.duration_seconds, meta_json = excluded.meta_json",++ "INSERT INTO clips (app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO files (key, app_id, user_id, original_name, size, mime, sha256) VALUES (?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO media_holds (object_id, kind, reason, created_by, note) VALUES (?, ?, ?, ?, ?)",++ "INSERT INTO media_invariant_violations (object_id, level, size_bytes, threshold_bytes) VALUES (?, ?, ?, ?)",++ "INSERT INTO media_locations (object_id, provider, bucket, key, storage_class, state, checksum, size_bytes, verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, owner_app, owner_user_id, visibility, lifecycle_status, mime_type, size_bytes, content_hash, canonical_provider, canonical_key, legacy_ref, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, 'private', 'ready', ?, ?, ?, 'local', ?, NULL, ?)",++ "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, owner_app, owner_user_id, visibility, lifecycle_status, mime_type, size_bytes, content_hash, canonical_provider, canonical_key, legacy_ref, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))",++ "INSERT INTO media_quota_reservations (object_id, app_id, namespace, bytes, expires_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(object_id) DO UPDATE SET bytes = excluded.bytes, namespace = excluded.namespace, expires_at = MAX(expires_at, excluded.expires_at), updated_at = CURRENT_TIMESTAMP",++ "INSERT INTO media_relationships (from_object_id, relation, to_object_id, metadata) VALUES (?, ?, ?, ?) ON CONFLICT(from_object_id, relation, to_object_id) DO UPDATE SET metadata = excluded.metadata",++ "INSERT INTO media_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",++ "INSERT INTO media_settings (key, value, description, type) VALUES (?, ?, 'Last object backfill report', 'json') ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",++ "INSERT INTO media_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",++ "INSERT INTO media_tier_decisions (vod_id, app_id, object_id, action, from_provider, to_provider, outcome, trigger, reason, inputs, thresholds, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT INTO media_upload_parts (upload_id, part_number, size_bytes, sha256) VALUES (?, ?, ?, ?) ON CONFLICT(upload_id, part_number) DO UPDATE SET size_bytes = excluded.size_bytes, sha256 = excluded.sha256, received_at = CURRENT_TIMESTAMP",++ "INSERT INTO media_uploads (id, object_id, app_id, part_size, total_size, parts_expected, expires_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now', ?))",++ "INSERT INTO media_variants (object_id, variant_name, derived_object_id, recipe) VALUES (?, ?, ?, ?) ON CONFLICT(object_id, variant_name) DO UPDATE SET derived_object_id = excluded.derived_object_id, recipe = excluded.recipe",++ "INSERT INTO media_verifications (object_id, verified_at, status, good_providers, detail, run_id) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(object_id) DO UPDATE SET verified_at = excluded.verified_at, status = excluded.status, good_providers = excluded.good_providers, detail = excluded.detail, run_id = excluded.run_id",++ "INSERT INTO media_verify_runs (started_at) VALUES (?)",++ "INSERT INTO paste_comments (paste_id, user_id, parent_id, anon_name, message, ip_address) VALUES (?, ?, ?, ?, ?, ?)",++ "INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, screenshot_path, metadata, ip_address) VALUES ('network', ?, ?, 'screenshot', ?, '', 'text', 'unlisted', ?, ?, NULL)",++ "INSERT INTO search_doc_pushes (kind, media_id, hash, revision, deleted, pushed_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(kind, media_id) DO UPDATE SET hash = excluded.hash, revision = excluded.revision, deleted = excluded.deleted, pushed_at = excluded.pushed_at",++ "INSERT INTO vods (app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public, visibility, clips_only) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",++ "INSERT OR IGNORE INTO apps (app_id, name, api_key_hash, quota_bytes, project_id, env) VALUES (?, ?, '', ?, ?, ?)",++ "INSERT OR IGNORE INTO content_visits (content_type, content_id, visitor) VALUES (?, ?, ?)",++ "INSERT OR IGNORE INTO content_visits (content_type, content_id, visitor, first_at, last_at, visits) VALUES (?, ?, ?, ?, ?, 1)",++ "INSERT OR IGNORE INTO media_namespaces (namespace, app_id, parent, owner) VALUES (?, ?, ?, ?)",++ "INSERT OR IGNORE INTO media_namespaces (namespace, app_id, parent, owner) VALUES (?, ?, NULL, ?)",++ "INSERT OR IGNORE INTO media_quota_reservations (object_id, app_id, namespace, bytes, expires_at) SELECT id, app_id, namespace, size_bytes, datetime('now', ?) FROM media_objects WHERE lifecycle_status = 'uploading' AND legacy_ref IS NULL",++ "INSERT OR IGNORE INTO media_settings (key, value, description, type) VALUES (?, ?, ?, ?)",++ "INSERT OR IGNORE INTO paste_likes (paste_id, user_id) VALUES (?, ?)",++ "INSERT OR REPLACE INTO media_settings (key, value, description, type) VALUES ('view_hash_secret', ?, 'Internal: salt for hashed visitor ids (do not share)', 'string')",++ "INSERT OR REPLACE INTO media_settings (key, value, description, type) VALUES ('views_migrated_v2', '1', 'Internal: legacy content_views imported', 'boolean')",++ "INSERT OR REPLACE INTO media_settings (key, value, type) VALUES ('namespaces.reservations_seeded', ?, 'string')",++ "SELECT 'vod' k, id ref, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL slug, NULL type, NULL content, NULL language FROM vods WHERE is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0 UNION ALL SELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE COALESCE(is_public,1) = 1 UNION ALL SELECT 'paste', id, title, NULL, NULL, NULL, NULL, created_at, app_id, slug, type, substr(COALESCE(content,''),1,300), language FROM pastes WHERE visibility = 'public' UNION ALL SELECT 'asset', id, name, username, channel_username, kind, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM assets ORDER BY created_at DESC LIMIT 48 OFFSET 0",++ "SELECT (SELECT COUNT(*) FROM vods WHERE is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0) + (SELECT COUNT(*) FROM clips WHERE COALESCE(is_public,1) = 1) + (SELECT COUNT(*) FROM pastes WHERE visibility = 'public') + (SELECT COUNT(*) FROM assets) c",++ "SELECT * FROM apps",++ "SELECT * FROM apps WHERE api_key_hash = ?",++ "SELECT * FROM apps WHERE app_id = ?",++ "SELECT * FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?",++ "SELECT * FROM assets WHERE id = ?",++ "SELECT * FROM assets WHERE kind = 'emote' ORDER BY created_at DESC LIMIT 48 OFFSET 0",++ "SELECT * FROM assets WHERE kind = 'sound' ORDER BY created_at DESC LIMIT 48 OFFSET 0",++ "SELECT * FROM clips WHERE COALESCE(is_public,1) = 1 ORDER BY created_at DESC LIMIT 48 OFFSET 0",++ "SELECT * FROM clips WHERE id = ?",++ "SELECT * FROM files WHERE app_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",++ "SELECT * FROM files WHERE app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL) ORDER BY created_at DESC LIMIT 48 OFFSET 0",++ "SELECT * FROM files WHERE key = ?",++ "SELECT * FROM files WHERE key = ? AND app_id = ?",++ "SELECT * FROM media_holds WHERE id = ?",++ "SELECT * FROM media_holds WHERE id = ? AND object_id = ?",++ "SELECT * FROM media_invariant_violations WHERE object_id = ?",++ "SELECT * FROM media_jobs WHERE app_id = ? AND idempotency_key = ?",++ "SELECT * FROM media_jobs WHERE app_id = ? AND job_type = ? AND request_hash = ? AND status IN ('queued', 'running') ORDER BY id DESC LIMIT 1",++ "SELECT * FROM media_jobs WHERE id = ?",++ "SELECT * FROM media_locations",++ "SELECT * FROM media_locations WHERE object_id = ? AND provider = 'local'",++ "SELECT * FROM media_locations WHERE object_id = ? AND provider = ?",++ "SELECT * FROM media_locations WHERE object_id = ? ORDER BY id",++ "SELECT * FROM media_namespaces WHERE app_id = ? ORDER BY namespace",++ "SELECT * FROM media_namespaces WHERE namespace = ?",++ "SELECT * FROM media_object_changes ORDER BY id LIMIT ?",++ "SELECT * FROM media_objects WHERE app_id = ? AND lifecycle_status != 'deleted' ORDER BY id DESC LIMIT ?",++ "SELECT * FROM media_objects WHERE id = ?",++ "SELECT * FROM media_objects WHERE legacy_ref = ?",++ "SELECT * FROM media_objects WHERE lifecycle_status = 'deleted' AND legacy_ref IS NULL AND deleted_at <= datetime('now', ?)",++ "SELECT * FROM media_quota_reservations WHERE object_id = ?",++ "SELECT * FROM media_tier_decisions WHERE app_id = ? ORDER BY id DESC LIMIT 20",++ "SELECT * FROM media_uploads WHERE id = ?",++ "SELECT * FROM media_uploads WHERE object_id = ? AND status IN ('active', 'completing') ORDER BY created_at DESC LIMIT 1",++ "SELECT * FROM media_variants WHERE object_id = ? AND variant_name = ?",++ "SELECT * FROM media_verify_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",++ "SELECT * FROM paste_comments WHERE id = ?",++ "SELECT * FROM paste_comments WHERE ip_address = ? AND created_at > datetime('now', '-' || ? || ' seconds') ORDER BY created_at DESC",++ "SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC",++ "SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?",++ "SELECT * FROM pastes WHERE id = ?",++ "SELECT * FROM pastes WHERE slug = ?",++ "SELECT * FROM pastes WHERE visibility = 'public' AND COALESCE(type,'paste') <> 'screenshot' ORDER BY created_at DESC LIMIT 48 OFFSET 0",++ "SELECT * FROM pastes WHERE visibility = 'public' AND type = 'screenshot' ORDER BY created_at DESC LIMIT 48 OFFSET 0",++ "SELECT * FROM vods WHERE COALESCE(is_recording, 0) = 0 AND (health_status IS NULL OR health_status NOT IN ('corrupt','zero_byte','missing_file')) AND (last_health_scan_at IS NULL OR last_health_scan_at <= datetime('now', ?)) ORDER BY (last_health_scan_at IS NULL) DESC, last_health_scan_at ASC LIMIT ?",++ "SELECT * FROM vods WHERE id = ?",++ "SELECT * FROM vods WHERE id = ? AND app_id = ?",++ "SELECT * FROM vods WHERE is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0 ORDER BY created_at DESC LIMIT 48 OFFSET 0",++ "SELECT * FROM vods WHERE quarantined_at IS NOT NULL AND quarantined_at <= datetime('now', ?) AND health_status IN ('corrupt','zero_byte','missing_file') AND COALESCE(is_recording, 0) = 0 ORDER BY quarantined_at ASC LIMIT ?",++ "SELECT * FROM vods WHERE storage_key LIKE ?",++ "SELECT *, COALESCE(duration_seconds, probe_duration_seconds, 0) AS duration_seconds FROM vods WHERE id = ?",++ "SELECT 1 AS x FROM media_settings WHERE key = 'namespaces.reservations_seeded'",++ "SELECT 1 AS x FROM pastes WHERE id = ?",++ "SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?",++ "SELECT 1 FROM paste_likes WHERE paste_id = ? AND user_id = ?",++ "SELECT 1 FROM pastes WHERE slug = ?",++ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'event_outbox'",++ "SELECT COALESCE(SUM(size), 0) AS bytes FROM files WHERE app_id = ?",++ "SELECT COALESCE(owner_app, app_id) AS owner_app, owner_user_id, count(*) AS objects FROM media_objects WHERE owner_subject IS NULL AND owner_user_id IS NOT NULL GROUP BY COALESCE(owner_app, app_id), owner_user_id ORDER BY 1, 2",++ "SELECT COALESCE(views,0) AS view_count, COALESCE(unique_views,0) AS unique_views FROM pastes WHERE id = ?",++ "SELECT COUNT(*) AS c FROM vods WHERE app_id = ? AND COALESCE(storage_provider, 'local') = 'local' AND COALESCE(is_recording, 0) = 0 AND created_at <= datetime('now', ?) AND COALESCE(view_count, 0) <= ? AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))",++ "SELECT COUNT(*) AS count FROM clips WHERE app_id = ?",++ "SELECT COUNT(*) AS count, COALESCE(SUM(file_size), 0) AS bytes, COALESCE(MIN(created_at), '') AS oldest, COALESCE(MAX(created_at), '') AS newest FROM vods WHERE app_id = ?",++ "SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes FROM files WHERE app_id = ?",++ "SELECT COUNT(*) AS count, SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots FROM pastes WHERE app_id = ?",++ "SELECT COUNT(*) AS n FROM apps",++ "SELECT COUNT(*) AS n FROM clips c WHERE c.vod_id IN (SELECT id FROM vods WHERE object_id = @o) OR c.object_id IN (SELECT from_object_id FROM media_relationships WHERE to_object_id = @o AND relation = 'clip_of')",++ "SELECT COUNT(*) AS n FROM media_jobs WHERE app_id = ? AND status IN ('queued', 'running')",++ "SELECT COUNT(*) AS n FROM media_namespaces WHERE app_id = ? AND parent IS NOT NULL",++ "SELECT COUNT(*) AS n FROM media_uploads WHERE status IN ('active', 'completing')",++ "SELECT COUNT(*) AS n FROM media_verifications",++ "SELECT COUNT(*) AS n, COALESCE(SUM(o.size_bytes), 0) AS b FROM media_objects o WHERE o.app_id = @app AND o.legacy_ref IS NULL AND (o.lifecycle_status = 'ready') AND o.id != @ex",++ "SELECT COUNT(*) AS n, COALESCE(SUM(r.bytes), 0) AS b FROM media_quota_reservations r JOIN media_objects o ON o.id = r.object_id AND o.lifecycle_status = 'uploading' WHERE r.app_id = @app AND r.object_id != @ex",++ "SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS b FROM files WHERE app_id = ?",++ "SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b FROM media_upload_parts WHERE upload_id = ?",++ "SELECT COUNT(*) AS total, SUM(CASE WHEN type = 'paste' THEN 1 ELSE 0 END) AS textPastes, SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots, SUM(CASE WHEN forked_from IS NOT NULL THEN 1 ELSE 0 END) AS forks, COALESCE(SUM(views), 0) AS totalViews, COALESCE(SUM(copies), 0) AS totalCopies, COALESCE(SUM(likes), 0) AS totalLikes FROM pastes WHERE app_id = ?",++ "SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND ip_address = ? AND created_at > datetime('now', '-1 day')",++ "SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND user_id = ? AND created_at > datetime('now', '-1 day')",++ "SELECT COUNT(*) as count FROM paste_comments WHERE paste_id = ? AND is_deleted = 0",++ "SELECT COUNT(*) c FROM assets WHERE kind = 'emote'",++ "SELECT COUNT(*) c FROM assets WHERE kind = 'sound'",++ "SELECT COUNT(*) c FROM clips WHERE COALESCE(is_public,1) = 1",++ "SELECT COUNT(*) c FROM files WHERE app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)",++ "SELECT COUNT(*) c FROM pastes WHERE visibility = 'public' AND COALESCE(type,'paste') <> 'screenshot'",++ "SELECT COUNT(*) c FROM pastes WHERE visibility = 'public' AND type = 'screenshot'",++ "SELECT COUNT(*) c FROM vods WHERE is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0",++ "SELECT COUNT(*) n FROM pastes WHERE app_id = ?",++ "SELECT COUNT(*) n FROM pastes WHERE app_id = ? AND COALESCE(type,'paste') <> 'screenshot'",++ "SELECT COUNT(*) n FROM pastes WHERE app_id = ? AND type = 'screenshot'",++ "SELECT DISTINCT app_id FROM media_objects WHERE kind IN ('vod', 'clip') AND visibility != 'private' AND lifecycle_status = 'ready'",++ "SELECT DISTINCT app_id FROM vods WHERE COALESCE(is_recording, 0) = 0 AND file_path IS NOT NULL",++ "SELECT DISTINCT o.app_id, o.namespace FROM media_objects o LEFT JOIN media_namespaces n ON n.namespace = o.namespace WHERE n.namespace IS NULL",++ "SELECT EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = @id OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = @id AND r.relation = 'clip_of') OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = @id))) AS held",++ "SELECT SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) AS localCount, SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) AS b2Count, SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) AS r2Count, SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN file_size ELSE 0 END) AS localBytes, SUM(CASE WHEN storage_provider = 'b2' THEN file_size ELSE 0 END) AS b2Bytes, SUM(CASE WHEN storage_provider = 'r2' THEN file_size ELSE 0 END) AS r2Bytes FROM vods WHERE app_id = ?",++ "SELECT SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount, SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count, SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count FROM clips",++ "SELECT SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount, SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count, SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count, SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN file_size ELSE 0 END) as localBytes, SUM(CASE WHEN storage_provider = 'b2' THEN file_size ELSE 0 END) as b2Bytes, SUM(CASE WHEN storage_provider = 'r2' THEN file_size ELSE 0 END) as r2Bytes FROM vods",++ "SELECT action, outcome, COUNT(*) AS n FROM media_tier_decisions WHERE app_id = ? AND decided_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day') GROUP BY action, outcome",++ "SELECT action, outcome, COUNT(*) AS n FROM media_tier_decisions WHERE decided_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day') GROUP BY action, outcome",++ "SELECT app_id FROM apps ORDER BY app_id",++ "SELECT app_id FROM apps WHERE api_key_hash = ?",++ "SELECT app_id FROM apps WHERE env = 'sandbox'",++ "SELECT app_id, count(*) AS objects, sum(owner_subject IS NOT NULL) AS already_set, sum(owner_subject IS NULL AND owner_user_id IS NOT NULL) AS missing, sum(owner_subject IS NULL AND owner_user_id IS NULL) AS no_owner FROM media_objects GROUP BY app_id ORDER BY app_id",++ "SELECT app_id, name, webhook_url, allowed_origins, quota_bytes, created_at FROM apps ORDER BY app_id",++ "SELECT app_id, namespace, lifecycle_status AS status, COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b FROM media_objects WHERE owner_subject = ? GROUP BY app_id, namespace, lifecycle_status ORDER BY app_id, namespace",++ "SELECT content_hash, metadata, lifecycle_status FROM media_objects WHERE id = ?",++ "SELECT content_type, content_id, ip, created_at FROM content_views",++ "SELECT created_at FROM pastes WHERE app_id = ? AND ip_address = ? ORDER BY created_at DESC LIMIT 1",++ "SELECT created_at FROM pastes WHERE app_id = ? AND user_id = ? ORDER BY created_at DESC LIMIT 1",++ "SELECT derived_object_id FROM media_variants WHERE object_id = ? AND variant_name = 'thumbnail'",++ "SELECT duration_seconds, duration_source FROM vods WHERE id = ?",++ "SELECT env FROM apps WHERE app_id = ?",++ "SELECT expires_at FROM media_quota_reservations WHERE object_id = ?",++ "SELECT file_path FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?",++ "SELECT file_path FROM assets WHERE file_path IS NOT NULL",++ "SELECT file_path FROM vods WHERE app_id = ? AND managed_stream_id = ? AND is_recording = 1 ORDER BY id DESC LIMIT 1",++ "SELECT file_path, master_file_path FROM vods WHERE COALESCE(is_recording,0)=1 OR quarantined_at IS NOT NULL OR health_status IN ('corrupt','zero_byte','needs_review')",++ "SELECT file_path, thumbnail_url FROM clips",++ "SELECT finished_at FROM media_verify_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",++ "SELECT from_object_id, relation, to_object_id FROM media_relationships WHERE from_object_id = ? OR to_object_id = ? ORDER BY id",++ "SELECT h.* FROM media_holds h JOIN media_objects o ON o.id = h.object_id WHERE h.id = ? AND o.app_id = ?",++ "SELECT h.*, h.object_id AS inherited_from FROM media_holds h WHERE h.released_at IS NULL AND h.object_id != @id AND ( h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = @id AND r.relation = 'clip_of') OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = @id)) ORDER BY h.id",++ "SELECT hash, revision, deleted FROM search_doc_pushes WHERE kind = ? AND media_id = ?",++ "SELECT id FROM clips WHERE status = 'failed' AND vod_id IS NOT NULL AND COALESCE(cut_attempts, 0) < ? AND (cut_next_at IS NULL OR cut_next_at <= CURRENT_TIMESTAMP) AND created_at >= datetime('now', '-30 days') AND NOT EXISTS (SELECT 1 FROM media_jobs j WHERE j.job_type = 'clip.cut' AND j.app_id = clips.app_id AND CAST(json_extract(j.params, '$.clip_id') AS INTEGER) = clips.id) ORDER BY created_at DESC LIMIT 4",++ "SELECT id FROM clips WHERE status = 'processing'",++ "SELECT id FROM media_jobs WHERE id = ? AND status = 'queued'",++ "SELECT id FROM media_jobs WHERE status IN ('queued', 'running')",++ "SELECT id FROM media_objects WHERE legacy_ref = ?",++ "SELECT id FROM media_uploads WHERE status IN ('active', 'completing')",++ "SELECT id FROM media_uploads WHERE status IN ('active', 'completing') AND expires_at < datetime('now')",++ "SELECT id FROM vods WHERE COALESCE(storage_provider, 'local') = 'local' AND COALESCE(is_recording, 0) = 0 AND created_at <= datetime('now', '-1 day') ORDER BY (last_accessed_at IS NOT NULL), last_accessed_at ASC, view_count ASC, file_size DESC LIMIT 10",++ "SELECT id FROM vods WHERE COALESCE(storage_provider, 'local') = 'local' AND COALESCE(is_recording, 0) = 0 AND file_path IS NULL AND created_at <= datetime('now', '-1 hour') AND COALESCE(health_status, 'ok') NOT IN ('missing_file', 'zero_byte')",++ "SELECT id FROM vods WHERE is_recording = 1",++ "SELECT id, app_id, COALESCE(owner_app, app_id) AS owner_app, owner_user_id FROM media_objects WHERE owner_subject IS NULL AND owner_user_id IS NOT NULL AND id > ? ORDER BY id LIMIT ?",++ "SELECT id, app_id, kind, legacy_ref FROM media_objects WHERE id = ?",++ "SELECT id, app_id, user_id, stream_id, managed_stream_id, title, visibility, is_public, file_path, file_size, storage_provider, storage_key, created_at FROM vods WHERE object_id = ?",++ "SELECT id, app_id, visibility, is_public, auto_generated, thumbnail_url, created_at FROM clips WHERE COALESCE(status, 'ready') = 'ready' AND COALESCE(is_public, 1) = 1 AND COALESCE(visibility, 'public') = 'public' AND COALESCE(auto_generated, 0) = 0 AND COALESCE(file_path, '') != '' AND app_id != 'live' AND EXISTS (SELECT 1 FROM media_objects ro WHERE ro.id = clips.object_id AND ro.lifecycle_status = 'ready' AND ro.kind IN ('vod', 'clip') AND (CASE WHEN json_valid(ro.metadata) THEN json_type(ro.metadata) END) = 'object' AND EXISTS (SELECT 1 FROM media_locations rl WHERE rl.object_id = ro.id AND rl.state = 'present' AND rl.verified_at IS NOT NULL AND (rl.checksum IS NULL OR ro.content_hash IS NULL OR rl.checksum = ro.content_hash))) ORDER BY created_at DESC LIMIT ?",++ "SELECT id, app_id, visibility, is_public, thumbnail_url, created_at FROM vods WHERE COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0 AND is_public = 1 AND COALESCE(visibility, 'public') = 'public' AND quarantined_at IS NULL AND COALESCE(health_status, 'ok') NOT IN ('corrupt', 'zero_byte', 'missing_file', 'needs_review') AND file_path IS NOT NULL AND app_id != 'live' AND EXISTS (SELECT 1 FROM media_objects ro WHERE ro.id = vods.object_id AND ro.lifecycle_status = 'ready' AND ro.kind IN ('vod', 'clip') AND (CASE WHEN json_valid(ro.metadata) THEN json_type(ro.metadata) END) = 'object' AND EXISTS (SELECT 1 FROM media_locations rl WHERE rl.object_id = ro.id AND rl.state = 'present' AND rl.verified_at IS NOT NULL AND (rl.checksum IS NULL OR ro.content_hash IS NULL OR rl.checksum = ro.content_hash))) ORDER BY created_at DESC LIMIT ?",++ "SELECT id, app_id, vod_id, stream_id, user_id, channel_user_id, title, visibility, is_public, file_path, status, created_at FROM clips WHERE object_id = ?",++ "SELECT id, file_path FROM vods WHERE id = ? AND app_id = ?",++ "SELECT id, file_path FROM vods WHERE is_recording = 1 ORDER BY id",++ "SELECT id, file_path FROM vods WHERE storage_tier = 'cold' AND COALESCE(storage_provider, 'local') = 'local'",++ "SELECT id, file_path, file_size, duration_seconds FROM vods WHERE is_recording = 0 AND is_public = 1 AND COALESCE(duration_seconds, 0) < 3 AND COALESCE(file_size, 0) < 10000000 AND (storage_provider IS NULL OR storage_provider = 'local') AND COALESCE(health_status, 'unknown') IN ('unknown', '')",++ "SELECT id, file_path, master_file_path, thumbnail_url FROM vods",++ "SELECT id, finished_at, objects_checked, reuploaded FROM media_verify_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",++ "SELECT id, job_type, status, error_code, attempts, max_attempts, created_at, finished_at FROM media_jobs WHERE object_id = ? ORDER BY id DESC LIMIT 10",++ "SELECT id, kind, reason, created_by, created_at FROM media_holds WHERE object_id = ? AND released_at IS NULL ORDER BY id",++ "SELECT id, lifecycle_status FROM media_objects WHERE id = ?",++ "SELECT id, lifecycle_status, deleted_at FROM media_objects WHERE legacy_ref = ?",++ "SELECT id, object_id FROM media_jobs WHERE app_id = ? AND status = 'proposed' AND created_by LIKE ?",++ "SELECT id, owner_subject, COALESCE(owner_app, app_id) AS owner_app, owner_user_id FROM media_objects WHERE id = ?",++ "SELECT id, size_bytes, content_hash, lifecycle_status FROM media_objects WHERE id = ?",++ "SELECT id, slug, app_id, user_id, title, type, visibility, screenshot_path, created_at FROM pastes WHERE object_id = ?",++ "SELECT id, state, size_bytes FROM media_locations WHERE object_id = ? AND provider = ?",++ "SELECT id, status FROM media_jobs WHERE app_id = ? AND idempotency_key = ?",++ "SELECT id, title, created_at, duration_seconds, visibility, is_recording, managed_stream_id, user_id, ai_overview, ai_transcript, ai_analyzed_at, app_id, is_public, clips_only FROM vods WHERE id = ?",++ "SELECT id, view_count, last_accessed_at FROM vods WHERE COALESCE(storage_provider, 'local') IN ('local', 'b2') AND COALESCE(is_recording, 0) = 0 AND COALESCE(view_count, 0) >= ? AND last_accessed_at IS NOT NULL AND last_accessed_at >= datetime('now', ?) LIMIT ?",++ "SELECT is_recording FROM vods WHERE id = ?",++ "SELECT j.* FROM media_jobs j WHERE j.app_id = ? ORDER BY j.id DESC LIMIT ?",++ "SELECT job_type, status, COUNT(*) AS n FROM media_jobs GROUP BY job_type, status",++ "SELECT key, app_id FROM files",++ "SELECT key, app_id, user_id, original_name, size, mime, created_at FROM files WHERE object_id = ?",++ "SELECT key, state FROM media_locations WHERE id = ?",++ "SELECT key, value FROM media_settings WHERE key LIKE 'storage_tier.%'",++ "SELECT l.* FROM media_locations l LEFT JOIN media_objects o ON o.id = l.object_id WHERE o.app_id = ? OR o.id IS NULL",++ "SELECT l.provider, l.bucket, l.key, l.state, l.verified_at, o.id AS object_id, o.app_id, o.kind, o.legacy_ref, o.lifecycle_status FROM media_locations l JOIN media_objects o ON o.id = l.object_id WHERE o.lifecycle_status != 'deleted' ORDER BY l.id",++ "SELECT last_at, visits FROM content_visits WHERE content_type = ? AND content_id = ? AND visitor = ?",++ "SELECT lifecycle_status FROM media_objects WHERE id = ?",++ "SELECT lifecycle_status, COUNT(*) AS n FROM media_objects GROUP BY lifecycle_status",++ "SELECT likes FROM pastes WHERE id = ?",++ "SELECT media_id FROM search_doc_pushes WHERE kind = ? AND deleted = 0",++ "SELECT namespace, app_id FROM media_namespaces ORDER BY app_id, namespace",++ "SELECT o.* FROM media_objects o LEFT JOIN media_verifications v ON v.object_id = o.id WHERE o.lifecycle_status = 'ready' ORDER BY (v.verified_at IS NOT NULL), v.verified_at, o.id LIMIT ?",++ "SELECT o.*, v.id AS violation_id FROM media_invariant_violations v LEFT JOIN media_objects o ON o.id = v.object_id WHERE v.resolved_at IS NULL",++ "SELECT o.id, o.size_bytes, l.id AS loc_id, l.key AS loc_key FROM media_objects o JOIN media_locations l ON l.object_id = o.id AND l.provider = 'local' AND l.state = 'present' WHERE o.lifecycle_status = 'ready' AND o.content_hash IS NULL",++ "SELECT object_id FROM vods WHERE id = ?",++ "SELECT object_id, variant_name, derived_object_id FROM media_variants WHERE object_id = ? OR derived_object_id = ? ORDER BY id",++ "SELECT part_number, size_bytes, sha256, received_at FROM media_upload_parts WHERE upload_id = ? ORDER BY part_number",++ "SELECT provider, bucket, key, state, size_bytes, verified_at FROM media_locations WHERE object_id = ? ORDER BY id",++ "SELECT provider, state, COUNT(*) AS n FROM media_locations GROUP BY provider, state",++ "SELECT provider, state, checksum, verified_at FROM media_locations WHERE object_id = ? ORDER BY id",++ "SELECT r.object_id, r.app_id, r.namespace, o.lifecycle_status, o.legacy_ref, o.metadata FROM media_quota_reservations r LEFT JOIN media_objects o ON o.id = r.object_id WHERE r.expires_at < datetime('now') ORDER BY r.expires_at LIMIT 1000",++ "SELECT r.relation, r.to_object_id FROM media_relationships r JOIN media_objects s ON s.id = r.to_object_id WHERE r.from_object_id = ? AND r.relation IN ('derived_from', 'thumbnail_of', 'clip_of') AND s.owner_subject = ? ORDER BY r.id LIMIT 1",++ "SELECT screenshot_path FROM pastes WHERE screenshot_path IS NOT NULL",++ "SELECT size_bytes FROM media_locations WHERE object_id = ? AND provider = 'local' AND state = 'present'",++ "SELECT status FROM media_jobs WHERE id = ?",++ "SELECT status FROM media_uploads WHERE id = ?",++ "SELECT status, COUNT(*) AS n FROM media_jobs GROUP BY status",++ "SELECT thumbnail_url FROM clips WHERE id = ?",++ "SELECT thumbnail_url FROM vods WHERE id = ?",++ "SELECT u.*, (SELECT COUNT(*) FROM media_upload_parts p WHERE p.upload_id = u.id) AS parts_received FROM media_uploads u WHERE status IN ('active', 'completing') ORDER BY created_at",++ "SELECT u.*, (SELECT COUNT(*) FROM media_upload_parts p WHERE p.upload_id = u.id) AS parts_received, (SELECT COALESCE(SUM(p.size_bytes), 0) FROM media_upload_parts p WHERE p.upload_id = u.id) AS bytes_received, (u.expires_at < datetime('now')) AS expired FROM media_uploads u WHERE u.status IN ('active', 'completing') ORDER BY u.created_at",++ "SELECT user_id, COUNT(*) AS vodCount, COALESCE(SUM(file_size), 0) AS totalSize FROM vods WHERE app_id = ? GROUP BY user_id ORDER BY totalSize DESC LIMIT 20",++ "SELECT value FROM media_settings WHERE key = 'namespaces.reservations_seeded'",++ "SELECT value FROM media_settings WHERE key = 'view_hash_secret'",++ "SELECT value FROM media_settings WHERE key = 'views_migrated_v2'",++ "SELECT value FROM media_settings WHERE key = ?",++ "SELECT value, type FROM media_settings WHERE key = ?",++ "SELECT variant_name, derived_object_id FROM media_variants WHERE object_id = ? ORDER BY id",++ "SELECT verified_at, status, detail FROM media_verifications WHERE object_id = ?",++ "UPDATE clips SET auto_generated = ? WHERE id = ?",++ "UPDATE clips SET cut_attempts = 0 WHERE id = ?",++ "UPDATE clips SET file_path = ?, duration_seconds = ?, end_time = ?, status = 'ready', cut_error = NULL, cut_next_at = NULL WHERE id = ?",++ "UPDATE clips SET status = 'failed', cut_error = ?, cut_next_at = ? WHERE id = ?",++ "UPDATE clips SET status = 'failed', cut_error = ?, cut_next_at = NULL WHERE id = ?",++ "UPDATE clips SET status = 'processing', cut_attempts = ?, cut_next_at = NULL WHERE id = ?",++ "UPDATE clips SET thumbnail_url = '/t/' || replace(thumbnail_url, rtrim(thumbnail_url, replace(thumbnail_url, '/', '')), '') WHERE thumbnail_url LIKE '%/api/thumbnails/%'",++ "UPDATE clips SET thumbnail_url = ? WHERE id = ?",++ "UPDATE clips SET title = ? WHERE id = ?",++ "UPDATE clips SET unique_views = (SELECT COUNT(*) FROM content_visits v WHERE v.content_type = 'clip' AND v.content_id = clips.id)",++ "UPDATE clips SET view_count = MAX(COALESCE(view_count,0), unique_views)",++ "UPDATE clips SET visibility = ?, is_public = ? WHERE id = ?",++ "UPDATE content_visits SET last_at = CURRENT_TIMESTAMP, visits = visits + 1 WHERE content_type = ? AND content_id = ? AND visitor = ?",++ "UPDATE media_holds SET released_at = CURRENT_TIMESTAMP, released_by = ? WHERE id = ? AND released_at IS NULL",++ "UPDATE media_invariant_violations SET level = ?, size_bytes = ?, threshold_bytes = ?, last_seen_at = CURRENT_TIMESTAMP, resolved_at = NULL, detected_at = CASE WHEN resolved_at IS NULL THEN detected_at ELSE CURRENT_TIMESTAMP END WHERE id = ?",++ "UPDATE media_invariant_violations SET resolved_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_jobs SET cancel_requested = 1, decided_by = COALESCE(decided_by, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_jobs SET checkpoint = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_jobs SET checkpoint = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'running' AND lease_token IS ?",++ "UPDATE media_locations SET bucket = ?, key = ?, storage_class = ?, state = ?, checksum = ?, size_bytes = ?, verified_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_locations SET checksum = ?, size_bytes = ?, verified_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND key = ?",++ "UPDATE media_locations SET state = 'present', size_bytes = ?, verified_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_locations SET state = ?, size_bytes = ? WHERE object_id = ? AND provider = ?",++ "UPDATE media_locations SET state = ?, size_bytes = COALESCE(?, size_bytes), verified_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND key = ?",++ "UPDATE media_namespaces SET used_bytes = ?, used_objects = ?, reserved_bytes = ?, reserved_objects = ?, reconciled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE namespace = ? AND app_id = ?",++ "UPDATE media_objects SET content_hash = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND content_hash IS NULL",++ "UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = CURRENT_TIMESTAMP, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_objects SET lifecycle_status = 'failed', metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND lifecycle_status = 'uploading'",++ "UPDATE media_objects SET lifecycle_status = 'failed', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND lifecycle_status = ?",++ "UPDATE media_objects SET lifecycle_status = ? WHERE id = ? AND lifecycle_status = ?",++ "UPDATE media_objects SET lifecycle_status = ?, deleted_at = NULL, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_objects SET metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_objects SET namespace = ? WHERE app_id = ? AND namespace = ?",++ "UPDATE media_objects SET owner_subject = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_subject IS NULL AND COALESCE(owner_app, app_id) = ? AND owner_user_id = ?",++ "UPDATE media_objects SET owner_subject = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_subject = ? AND COALESCE(owner_app, app_id) = ? AND owner_user_id = ?",++ "UPDATE media_objects SET size_bytes = ?, content_hash = ? WHERE id = ? AND size_bytes IS ? AND content_hash IS ?",++ "UPDATE media_objects SET size_bytes = ?, content_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND size_bytes IS ? AND content_hash IS ?",++ "UPDATE media_quota_reservations SET bytes = ?, updated_at = CURRENT_TIMESTAMP WHERE object_id = ?",++ "UPDATE media_quota_reservations SET expires_at = MAX(expires_at, ?), updated_at = CURRENT_TIMESTAMP WHERE object_id = ?",++ "UPDATE media_uploads SET status = 'active', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'completing'",++ "UPDATE media_uploads SET status = 'completed', completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_uploads SET status = 'completing', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'active'",++ "UPDATE media_uploads SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IN ('active', 'completing')",++ "UPDATE media_uploads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE media_verify_runs SET finished_at = ?, objects_checked = ?, locations_checked = ?, good = ?, no_good_copy = ?, unverifiable = ?, reuploaded = ?, reupload_failed = ?, no_good_copy_total = ?, error = ? WHERE id = ?",++ "UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?",++ "UPDATE pastes SET copies = copies + 1 WHERE slug = ?",++ "UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?",++ "UPDATE pastes SET views = COALESCE(views,0) + 1, unique_views = COALESCE(unique_views,0) + 1 WHERE id = ?",++ "UPDATE pastes SET views = views + 1 WHERE id = ?",++ "UPDATE vods SET duration_seconds = ?, file_size = ? WHERE id = ?",++ "UPDATE vods SET duration_seconds = ?, file_size = ?, duration_source = 'probe' WHERE id = ?",++ "UPDATE vods SET duration_seconds = ?, file_size = ?, probe_duration_seconds = ?, duration_source = ?, last_health_scan_at = datetime('now') WHERE id = ?",++ "UPDATE vods SET duration_seconds = ?, probe_duration_seconds = ?, duration_source = ? WHERE id = ?",++ "UPDATE vods SET duration_seconds = ?, probe_duration_seconds = ?, duration_source = ? WHERE id = ? AND COALESCE(duration_seconds, 0) = ? AND COALESCE(duration_source, '') = ? AND COALESCE(is_recording, 0) = 0",++ "UPDATE vods SET file_path = ?, file_size = ?, is_recording = 1 WHERE id = ?",++ "UPDATE vods SET file_size = ?, duration_seconds = ? WHERE id = ?",++ "UPDATE vods SET health_status = 'missing_file', health_issues_json = ?, last_health_scan_at = datetime('now'), quarantined_at = COALESCE(quarantined_at, datetime('now')), is_public = 0 WHERE id = ?",++ "UPDATE vods SET health_status = 'needs_review', health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",++ "UPDATE vods SET is_recording = 0, duration_seconds = 0, duration_source = 'unknown', health_status = 'needs_review', health_issues_json = ?, last_health_scan_at = datetime('now') WHERE id = ?",++ "UPDATE vods SET is_recording = 0, duration_seconds = 0, duration_source = 'unknown', health_status = 'needs_review', health_issues_json = ?, probe_duration_seconds = 0, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",++ "UPDATE vods SET is_recording = 0, duration_seconds = 0, duration_source = 'unknown', health_status = 'needs_review', health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",++ "UPDATE vods SET is_recording = 0, duration_seconds = ?, duration_source = ?, health_status = ?, health_issues_json = ?, probe_duration_seconds = ?, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",++ "UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, quarantined_at = COALESCE(quarantined_at, datetime('now')), is_public = 0, last_health_scan_at = datetime('now') WHERE id = ?",++ "UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",++ "UPDATE vods SET is_recording = 1, file_path = ? WHERE id = ?",++ "UPDATE vods SET last_accessed_at = datetime('now') WHERE id = ?",++ "UPDATE vods SET master_file_path = ? WHERE id = ?",++ "UPDATE vods SET master_file_path = NULL WHERE id = ?",++ "UPDATE vods SET quarantined_at = NULL, health_status = 'ok' WHERE id = ?",++ "UPDATE vods SET storage_provider = 'b2' WHERE id = ?",++ "UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?",++ "UPDATE vods SET storage_provider = 'local' WHERE id = ?",++ "UPDATE vods SET storage_provider = 'r2', storage_key = ? WHERE id = ?",++ "UPDATE vods SET thumbnail_url = '/t/' || replace(thumbnail_url, rtrim(thumbnail_url, replace(thumbnail_url, '/', '')), '') WHERE thumbnail_url LIKE '%/api/thumbnails/%'",++ "UPDATE vods SET thumbnail_url = ? WHERE id = ?",++ "UPDATE vods SET unique_views = (SELECT COUNT(*) FROM content_visits v WHERE v.content_type = 'vod' AND v.content_id = vods.id)",++ "UPDATE vods SET view_count = MAX(COALESCE(view_count,0), unique_views)",++ "UPDATE vods SET visibility = ?, is_public = ? 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,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,28 @@++'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:++ * - N1_SQL_OUT: every SQL text the process prepares or executes on the main database (DB_PATH) is++ * written there as JSON when it exits;++ * - the restore-drill sandbox (MEDIA_DRILL: no outbound connection, program, listener or timer) is++ * kept, but its read-only guard is lifted, so the old client's writes reach their routes.++ */++const fs = require('fs');++const path = require('path');++++const cwd = process.cwd();++const out = process.env.N1_SQL_OUT;++if (out) {++ const Database = require(path.join(cwd, 'node_modules', 'better-sqlite3'));++ const main = path.resolve(process.env.DB_PATH || '');++ const seen = new Set();++ const note = (db, sql) => { try { if (path.resolve(db.name) === main) 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 { /* */ } });++}++++const drill = require(path.join(cwd, 'server', 'drill'));++drill.readOnly = (req, res, next) => next();nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * Media'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.++ *++ * Media's clients: its server-rendered pages (/, /browse, /v, /c, /p, /me: an open tab holds the page++ * N-1 served, the player's media URLs and every link and form in it), the object explorer's script,++ * and the openvibe-sdk media and objects clients (files v1, Object API v2 and jobs) as that release++ * installed them, signed in with an app key the way the SDK is.++ *++ * A release boots as a real `node server/index.js` in the restore-drill sandbox (MEDIA_DRILL: loopback++ * only, no outbound connection, program or background job; stored bytes answer 503), with++ * test/n-1/preload.js lifting the drill's read-only guard so writes reach their routes.++ */++const fs = require('fs');++const net = require('net');++const path = require('path');++const { spawn, spawnSync } = require('child_process');++const { readTree } = require('./harness');++++const PRELOAD = path.join(__dirname, 'preload.js');++const APP_BEARER = 'n1n1n1n1n1n1n1n1n1n1n1n1';++const STORAGE = ['VOD_PATH:vods', 'CLIPS_PATH:clips', 'PASTES_PATH:pastes', 'FILES_PATH:files', 'OBJECTS_PATH:objects', 'ASSETS_PATH:assets', 'THUMBNAILS_PATH:thumbnails'];++++function freePort() {++ return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });++}++++/** A clean environment: the release's storage under dataDir; nothing from the caller's shell but PATH and HOME. */++function baseEnv(dbPath, dataDir, extra) {++ const env = { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR || '/tmp', NODE_ENV: 'test', DB_PATH: dbPath, MEDIA_PUBLIC_URL: 'https://openvibe.media', ...extra };++ for (const s of STORAGE) { const [k, d] = s.split(':'); env[k] = path.join(dataDir, d); fs.mkdirSync(env[k], { recursive: true }); }++ return env;++}++++const SEED = `++ console.log = () => {}; console.warn = () => {};++ const fs = require('fs'); const path = require('path');++ const db = require('./server/db/database');++ db.getDb();++ try { require('./server/views/service').ensureSchema(); } catch (e) { /* older release */ }++ db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: ${JSON.stringify(APP_BEARER)} });++ const put = (dir, name) => { const f = path.join(process.env[dir], name); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, 'bytes of ' + name); return f; };++ db.run("INSERT INTO vods (app_id, user_id, title, file_path, file_size, thumbnail_url, duration_seconds, is_public, visibility) VALUES ('live', 1, 'N-1 VOD', ?, 18, '/t/vod-1-1.jpg', 60, 1, 'public')", [put('VOD_PATH', 'vod-1.mp4')]);++ db.run("INSERT INTO clips (app_id, vod_id, user_id, title, file_path, duration_seconds, status, is_public, visibility) VALUES ('live', 1, 1, 'N-1 clip', ?, 10, 'ready', 1, 'public')", [put('CLIPS_PATH', 'clip-1.mp4')]);++ db.run("INSERT INTO pastes (app_id, slug, type, title, content, visibility) VALUES ('live', 'n1paste', 'paste', 'N-1 paste', 'hello from N-1', 'public')");++ db.run("INSERT INTO files (key, app_id, original_name, size, mime) VALUES ('n1file.txt', 'live', 'n1file.txt', 17, 'text/plain')");++ put('FILES_PATH', 'live/n1file.txt'); put('THUMBNAILS_PATH', 'vod-1-1.jpg');++ try { require('./server/objects/backfill').backfill({ onlyMissing: true }); } catch (e) { /* older release */ }++ db.close();++`;++++module.exports = {++ service: 'media',++++ clientFiles(dir) {++ return readTree(dir, ['server/public', 'server/me', 'node_modules/openvibe-sdk/src/media.js', 'node_modules/openvibe-sdk/src/objects.js'], ['.js']);++ },++ callers: [++ { name: 'fetch' },++ { name: 'fetchJson' },++ { name: 'call', object: true },++ { name: 'json', object: true },++ ],++ forms: true,++ keep: () => true,++ crawl: ['/', '/browse', '/v/1', '/c/1', '/p/n1paste', '/me', '/updates'],++ origins: ['https://openvibe.media'],++ /** Values for template expressions, first match wins (the rows SEED writes). */++ samples: [++ [/app/i, 'live'],++ [/key/i, 'n1file.txt'],++ [/upload_?id|uploadId/i, 'n1-upload'],++ [/object_?id|created\.id|^enc\(id\)$/i, '@object'],++ [/id\)*$/i, '1'],++ [/slug/i, 'n1paste'],++ [/scope/i, ''],++ [/^n$/, '1'],++ ],++++ sqlDirs: ['server'],++ ledgerTables: [],++++ /** Seeds a database with the release in `dir` (opening it migrates first). */++ seed({ dir, dbPath, dataDir }) {++ const r = spawnSync(process.execPath, ['-e', SEED], { cwd: dir, encoding: 'utf8', timeout: 120000, env: baseEnv(dbPath, dataDir, {}) });++ if (r.status !== 0) throw new Error(`seeding failed:\n${String(r.stderr || '').slice(-2000)}`);++ },++++ /** Boots the release in `dir` → { url, headers(auth), close() }; 'user' is the SDK's app key. */++ async boot({ dir, dbPath, dataDir, sqlOut = '' }) {++ const port = await freePort();++ const child = spawn(process.execPath, ['-r', PRELOAD, 'server/index.js'], {++ cwd: dir,++ env: baseEnv(dbPath, dataDir, { MEDIA_DRILL: '1', HOST: '127.0.0.1', PORT: String(port), N1_SQL_OUT: sqlOut }),++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let log = '';++ child.stdout.on('data', (c) => { log = (log + c).slice(-20000); });++ child.stderr.on('data', (c) => { log = (log + c).slice(-20000); });++ const exited = new Promise((resolve) => child.on('exit', resolve));++ const url = `http://127.0.0.1:${port}`;++ for (let i = 0; ; i++) {++ if (child.exitCode != null) throw new Error(`the release in ${dir} exited while booting:\n${log.slice(-3000)}`);++ try { const r = await fetch(`${url}/release.json`); if (r.status === 200) break; } catch { /* not listening yet */ }++ if (i > 300) { child.kill('SIGKILL'); throw new Error(`the release in ${dir} did not become ready:\n${log.slice(-3000)}`); }++ await new Promise((r) => setTimeout(r, 100));++ }++ // The seeded VOD's object (ids are random): the SDK's object calls address it.++ let object = null;++ try {++ const Database = require('better-sqlite3');++ const d = new Database(dbPath, { readonly: true, fileMustExist: true });++ try { object = (d.prepare("SELECT id FROM media_objects WHERE kind = 'vod' ORDER BY created_at, id LIMIT 1").get() || {}).id || null; } finally { d.close(); }++ } catch { /* an older release without media_objects */ }++ return {++ url,++ ids: { object },++ log: () => log,++ headers: (auth) => (auth === 'user' ? { authorization: `Bearer ${APP_BEARER}` } : {}),++ async close() {++ if (child.exitCode == null) child.kill('SIGTERM');++ const t = setTimeout(() => { if (child.exitCode == null) child.kill('SIGKILL'); }, 5000);++ 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 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:
@@ -346,6 +346,17 @@ backup and starts a second Media from this checkout on 127.0.0.1:14100 with `MED
@@ -346,6 +346,17 @@ backup and starts a second Media from this checkout on 127.0.0.1:14100 with `MED
`test/drill-mode.test.js` boots the real server that way against a copy whose app has a webhook URL
`test/drill-mode.test.js` boots the real server that way against a copy whose app has a webhook URL
and checks each point.
and checks each point.
+## N-1: the previous release against this one
+
+`test/n-1.test.js` (roadmap WS-P task 11, in `npm test`) boots this checkout in the drill sandbox
+(with writes let through) on a database the previous release created, so this release's migrations
+run over it, and replays `test/fixtures/n-1/`: every call the previous release's clients make (the
+openvibe-sdk media and objects clients with an app key, the object explorer, and every player URL,
+link, script and form of the `/`, `/browse`, `/v`, `/c`, `/p`, `/me` and `/updates` pages it served),
+each answered compatibly (status, JSON, the response fields the client reads). Then every SQL statement
+the previous release runs must still prepare on the migrated schema. After each deploy, record the
+release now in production as the next N-1 (`npm run n-1:record [ref]`) and commit the fixtures.
+
## Client addresses
## Client addresses
Express `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that the
Express `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that the
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
"start": "node server/index.js",
"start": "node server/index.js",
"init-db": "node server/db/init.js",
"init-db": "node server/db/init.js",
"smoke": "bash scripts/smoke-test.sh",
"smoke": "bash scripts/smoke-test.sh",
- "test": "for f in test/*.test.js; do node \"$f\" || exit 1; done"
+ "test": "for f in test/*.test.js; do node \"$f\" || exit 1; done",
+ "n-1:record": "node scripts/n-1-record.js"
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
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,836 @@
@@ -0,0 +1,836 @@
+{
+ "service": "media",
+ "release": "b7fbe65029f38fd3ec6b10919eab9b8ba13df9d4",
+ "recorded_at": "2026-09-26T14:14:01.055Z",
+ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
+ "manifest": {
+ "service": "media",
+ "release": "b7fbe65029f3",
+ "released_at": "2026-09-26T13:16:26.000Z",
+ "booted_at": "2026-09-26T14:14:00.188Z",
+ "contracts_version": "0.54.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": "b7fbe65029f3"
+ },
+ "server": {
+ "kind": "server",
+ "version": "b7fbe65029f3"
+ }
+ },
+ "assets": {},
+ "schema_generation": null,
+ "schema_compatible_from": null,
+ "contract_ranges": {},
+ "metrics_url": "/release-metrics"
+ },
+ "dynamic_calls": 3,
+ "calls": [
+ {
+ "method": "GET",
+ "path": "/api/v1/live/files",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/media.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/live/files",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "files": "array",
+ "files[]": "object",
+ "files[].key": "string",
+ "files[].app_id": "string",
+ "files[].user_id": "null",
+ "files[].original_name": "string",
+ "files[].size": "number",
+ "files[].mime": "string",
+ "files[].sha256": "null",
+ "files[].url": "string",
+ "files[].created_at": "string",
+ "used_bytes": "number",
+ "limit": "number",
+ "offset": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/media.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/live/files/n1file.txt",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/media.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/live/files/n1file.txt",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "key": "string",
+ "app_id": "string",
+ "user_id": "null",
+ "original_name": "string",
+ "size": "number",
+ "mime": "string",
+ "sha256": "null",
+ "url": "string",
+ "created_at": "string"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/media.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/me/objects/1",
+ "auth": "anon",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "server/me/client.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/me/objects/1",
+ "auth": "user",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "server/me/client.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/me/objects?status=uploading&limit=100",
+ "auth": "anon",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "server/me/client.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/me/objects?status=uploading&limit=100",
+ "auth": "user",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "server/me/client.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/me/objects",
+ "auth": "anon",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "server/me/client.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/me/objects",
+ "auth": "user",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "server/me/client.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/jobs",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/jobs",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "jobs": "array",
+ "next_cursor": "null",
+ "limit": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/jobs/{@object}",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/jobs/{@object}",
+ "auth": "user",
+ "status": 404,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/objects",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/objects",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "objects": "array",
+ "objects[]": "object",
+ "objects[].id": "string",
+ "objects[].legacy_ref": "string",
+ "objects[].app_id": "string",
+ "objects[].namespace": "string",
+ "objects[].kind": "string",
+ "objects[].owner": "object",
+ "objects[].owner.subject": "null",
+ "objects[].owner.app": "string",
+ "objects[].owner.user_id": "number",
+ "objects[].visibility": "string",
+ "objects[].lifecycle_status": "string",
+ "objects[].mime_type": "string",
+ "objects[].size_bytes": "number",
+ "objects[].content_hash": "null",
+ "objects[].metadata": "object",
+ "objects[].metadata.title": "string",
+ "objects[].metadata.duration_seconds": "number",
+ "objects[].metadata.health_status": "string",
+ "objects[].metadata.managed_stream_id": "null",
+ "objects[].metadata.recording": "boolean",
+ "objects[].held": "boolean",
+ "objects[].readiness": "object",
+ "objects[].readiness.metadata": "boolean",
+ "objects[].readiness.bytes_verified": "boolean",
+ "objects[].readiness.playable": "boolean",
+ "objects[].readiness.reason": "string",
+ "objects[].public_url": "string",
+ "objects[].created_at": "string",
+ "objects[].updated_at": "string",
+ "objects[].deleted_at": "null",
+ "objects[].metadata.auto_generated": "boolean",
+ "objects[].metadata.filename": "string",
+ "next_cursor": "null",
+ "limit": "number"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/objects/{@object}",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/objects/{@object}",
+ "auth": "user",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "id": "string",
+ "legacy_ref": "string",
+ "app_id": "string",
+ "namespace": "string",
+ "kind": "string",
+ "owner": "object",
+ "owner.subject": "null",
+ "owner.app": "string",
+ "owner.user_id": "number",
+ "visibility": "string",
+ "lifecycle_status": "string",
+ "mime_type": "string",
+ "size_bytes": "number",
+ "content_hash": "null",
+ "metadata": "object",
+ "metadata.title": "string",
+ "metadata.duration_seconds": "number",
+ "metadata.health_status": "string",
+ "metadata.managed_stream_id": "null",
+ "metadata.recording": "boolean",
+ "held": "boolean",
+ "readiness": "object",
+ "readiness.metadata": "boolean",
+ "readiness.bytes_verified": "boolean",
+ "readiness.playable": "boolean",
+ "readiness.reason": "null",
+ "public_url": "string",
+ "created_at": "string",
+ "updated_at": "string",
+ "deleted_at": "null",
+ "locations": "array",
+ "locations[]": "object",
+ "locations[].provider": "string",
+ "locations[].storage_class": "string",
+ "locations[].state": "string",
+ "locations[].size_bytes": "number",
+ "locations[].verified_at": "string",
+ "locations[].canonical": "boolean"
+ },
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/objects/{@object}/download",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/objects/{@object}/download",
+ "auth": "user",
+ "status": 302,
+ "kind": "empty",
+ "location": "/v/1",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/objects/{@object}/multipart/n1-upload",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v2/live/objects/{@object}/multipart/n1-upload",
+ "auth": "user",
+ "status": 404,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/me",
+ "auth": "anon",
+ "status": 503,
+ "kind": "html",
+ "from": [
+ "server/me/pages.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/me",
+ "auth": "user",
+ "status": 503,
+ "kind": "html",
+ "from": [
+ "server/me/pages.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/me/ops",
+ "auth": "anon",
+ "status": 503,
+ "kind": "html",
+ "from": [
+ "server/me/pages.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/me/ops",
+ "auth": "user",
+ "status": 503,
+ "kind": "html",
+ "from": [
+ "server/me/pages.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",
+ "scope": "string"
+ },
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/?tab=all",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/?tab=videos",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/?tab=clips",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/?tab=images",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/?tab=text",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/?tab=emotes",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/?tab=sounds",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/?tab=thumbnails",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/?tab=files",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v/1",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/t/vod-1-1.jpg",
+ "auth": "anon",
+ "accept": "html",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/c/1",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page /"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/p/n1paste",
+ "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": "/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": "/browse",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "html",
+ "from": [
+ "page"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/theme-loader.js?v=d9b9af081efe",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /v/1"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v/1?raw=1",
+ "auth": "anon",
+ "accept": "html",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "page /v/1"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/v/1/transcript.json",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "json",
+ "reads": {
+ "vod_id": "number",
+ "title": "string",
+ "created_at": "string",
+ "duration_seconds": "number",
+ "visibility": "string",
+ "is_recording": "boolean",
+ "managed_stream_id": "null",
+ "ai_overview": "null",
+ "ai_analyzed_at": "null",
+ "transcript": "null",
+ "segments": "array",
+ "sound_events": "array",
+ "app_id": "string",
+ "generated_at": "string"
+ },
+ "from": [
+ "page /v/1"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/c/1?raw=1",
+ "auth": "anon",
+ "accept": "html",
+ "status": 503,
+ "kind": "json",
+ "from": [
+ "page /c/1"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/p/n1paste/raw",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "text",
+ "from": [
+ "page /p/n1paste"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/shared/shipped.js?v=18e4e7ae4ec3",
+ "auth": "anon",
+ "accept": "html",
+ "status": 200,
+ "kind": "other",
+ "from": [
+ "page /updates"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/live/files",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/media.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/live/files/n1file.txt",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/media.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v2/live/jobs",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v2/live/jobs/{@object}/approve",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v2/live/jobs/{@object}/cancel",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v2/live/objects",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v2/live/objects/{@object}/complete",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v2/live/objects/{@object}",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v2/live/objects/{@object}/multipart/n1-upload/complete",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v2/live/objects/{@object}/multipart/n1-upload/parts/1",
+ "auth": "anon",
+ "status": 401,
+ "kind": "json",
+ "from": [
+ "node_modules/openvibe-sdk/src/objects.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/me/ops/recompute",
+ "auth": "anon",
+ "status": 503,
+ "kind": "html",
+ "from": [
+ "server/me/pages.js"
+ ]
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,423 @@
@@ -0,0 +1,423 @@
+{
+ "service": "media",
+ "release": "b7fbe65029f38fd3ec6b10919eab9b8ba13df9d4",
+ "recorded_at": "2026-09-26T14:14:01.055Z",
+ "note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
+ "user_version": 0,
+ "schema": [
+ "CREATE TABLE apps (\n app_id TEXT PRIMARY KEY,\n name TEXT NOT NULL DEFAULT '',\n api_key_hash TEXT NOT NULL,\n webhook_url TEXT,\n webhook_secret TEXT,\n allowed_origins TEXT DEFAULT '[]', -- JSON array of origins for browser (user-JWT) calls\n quota_bytes INTEGER DEFAULT 0, -- files quota; 0 = unlimited\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n, project_id TEXT, env TEXT)",
+ "CREATE TABLE assets (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n app_id TEXT NOT NULL DEFAULT 'live',\n kind TEXT NOT NULL, -- 'emote' | 'sound'\n name TEXT NOT NULL, -- emote code or !command\n file_path TEXT NOT NULL,\n mime TEXT DEFAULT 'application/octet-stream',\n user_id INTEGER, -- uploader (app-local id)\n username TEXT DEFAULT '', -- uploader name snapshot\n channel_username TEXT DEFAULT '', -- channel it belongs to\n duration_seconds REAL DEFAULT 0,\n meta_json TEXT DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE clips (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n app_id TEXT NOT NULL DEFAULT 'live',\n vod_id INTEGER,\n stream_id INTEGER,\n user_id INTEGER,\n channel_user_id INTEGER, -- owner of the clipped channel/stream (list filter)\n title TEXT DEFAULT 'Untitled Clip',\n description TEXT DEFAULT '',\n file_path TEXT,\n thumbnail_url TEXT,\n start_time REAL NOT NULL DEFAULT 0,\n end_time REAL NOT NULL DEFAULT 0,\n duration_seconds REAL DEFAULT 0,\n is_public INTEGER DEFAULT 1,\n visibility TEXT DEFAULT 'public',\n status TEXT DEFAULT 'ready', -- processing | ready | failed (imports default ready)\n view_count INTEGER DEFAULT 0,\n auto_generated INTEGER DEFAULT 0,\n storage_provider TEXT DEFAULT 'local',\n storage_key TEXT,\n ai_overview TEXT,\n ai_transcript TEXT,\n ai_analyzed_at DATETIME,\n object_id TEXT, -- media_objects.id\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n, cut_error TEXT, cut_attempts INTEGER DEFAULT 0, cut_next_at DATETIME, unique_views INTEGER DEFAULT 0)",
+ "CREATE TABLE content_views (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n content_type TEXT NOT NULL CHECK(content_type IN ('vod', 'clip')),\n content_id INTEGER NOT NULL,\n ip TEXT NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(content_type, content_id, ip)\n)",
+ "CREATE TABLE content_visits (\n content_type TEXT NOT NULL, -- vod | clip | paste | file\n content_id INTEGER NOT NULL,\n visitor TEXT NOT NULL, -- 'u:<userId>' or 'ip:<hmac>'\n first_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n visits INTEGER DEFAULT 1,\n PRIMARY KEY (content_type, content_id, visitor)\n )",
+ "CREATE TABLE files (\n key TEXT PRIMARY KEY,\n app_id TEXT NOT NULL DEFAULT 'live',\n user_id INTEGER,\n original_name TEXT,\n size INTEGER NOT NULL DEFAULT 0,\n mime TEXT DEFAULT 'application/octet-stream',\n sha256 TEXT,\n object_id TEXT, -- media_objects.id\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n, unique_views INTEGER DEFAULT 0, view_count INTEGER DEFAULT 0)",
+ "CREATE TABLE media_holds (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL,\n kind TEXT NOT NULL CHECK(kind IN ('moderation', 'dmca', 'creator_pin', 'admin', 'evidence')),\n reason TEXT NOT NULL DEFAULT '',\n created_by TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n released_at DATETIME,\n released_by TEXT,\n note TEXT\n)",
+ "CREATE TABLE media_invariant_violations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL UNIQUE,\n level TEXT NOT NULL CHECK(level IN ('warn', 'violation')),\n size_bytes INTEGER NOT NULL,\n threshold_bytes INTEGER NOT NULL,\n detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n resolved_at DATETIME\n)",
+ "CREATE TABLE media_jobs (\n id TEXT PRIMARY KEY, -- mjob_<ULID>\n app_id TEXT NOT NULL, -- the tenant that owns the job\n object_id TEXT, -- the object it works on (NULL for tenant-wide jobs: invariant.scan)\n job_type TEXT NOT NULL, -- thumbnail.regenerate | invariant.scan | object.split | object.remux\n status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('proposed', 'queued', 'running', 'succeeded', 'failed', 'cancelled')),\n idempotency_key TEXT, -- unique per tenant: a repeat answers with the same job\n request_hash TEXT, -- sha256 of { type, object_id, params }: a different request under a used key is refused\n params TEXT NOT NULL DEFAULT '{}',\n result TEXT,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n run_after DATETIME, -- queued: not before (retry backoff)\n lease_until DATETIME, -- running: renewed while the worker holds the job\n lease_token TEXT, -- running: the claim's random fencing token; renew/checkpoint/succeed/fail must match it\n checkpoint TEXT, -- handler progress (JSON); a retry resumes from it\n error TEXT,\n error_code TEXT, -- stable code of the last failure (media_unavailable, quota_exceeded, …)\n cancel_requested INTEGER NOT NULL DEFAULT 0,\n created_by TEXT, -- svc:<id> | app:<app> | app:<app>:user:<id> | system:<what>\n owner_user_id INTEGER, -- the app's user it was created for (X-OV-User-Id), if any\n decided_by TEXT, -- who approved or rejected a proposal\n decided_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n started_at DATETIME,\n finished_at DATETIME\n)",
+ "CREATE TABLE media_locations (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL,\n provider TEXT NOT NULL CHECK(provider IN ('local', 'b2', 'r2')),\n bucket TEXT,\n key TEXT NOT NULL, -- absolute path for local, object key for b2/r2\n storage_class TEXT, -- hot (local) | cold (b2 canonical) | cache (r2)\n state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('present', 'missing', 'pending', 'corrupt')),\n checksum TEXT,\n size_bytes INTEGER,\n verified_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(object_id, provider)\n)",
+ "CREATE TABLE media_namespaces (\n namespace TEXT PRIMARY KEY,\n app_id TEXT NOT NULL, -- the tenant it belongs to (and whose quota it counts against)\n parent TEXT, -- NULL for a root\n owner TEXT NOT NULL, -- service:<app_id> (a first-party tenant) | project:<prj_…> (a developer project)\n policy TEXT NOT NULL DEFAULT '{}', -- JSON: kinds, visibilities, max_object_bytes, strict_verbs (children inherit)\n quota_bytes INTEGER,\n quota_objects INTEGER,\n used_bytes INTEGER NOT NULL DEFAULT 0,\n used_objects INTEGER NOT NULL DEFAULT 0,\n reserved_bytes INTEGER NOT NULL DEFAULT 0,\n reserved_objects INTEGER NOT NULL DEFAULT 0,\n reconciled_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE media_object_changes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL,\n change TEXT NOT NULL CHECK(change IN ('deleted', 'visibility_changed')),\n previous_visibility TEXT,\n visibility TEXT,\n changed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))\n)",
+ "CREATE TABLE media_objects (\n id TEXT PRIMARY KEY, -- med_<ULID> (time-sortable)\n app_id TEXT NOT NULL, -- tenant\n namespace TEXT NOT NULL, -- a media_namespaces row: the tenant's root (app id, or app.<project_id>[.sandbox]) or a child of it\n kind TEXT NOT NULL CHECK(kind IN ('vod', 'clip', 'file', 'thumbnail', 'screenshot', 'avatar', 'asset')),\n owner_subject TEXT, -- canonical subject (usr_<ULID>) when known\n owner_app TEXT, -- legacy owner: the app whose user-id space owner_user_id is in\n owner_user_id INTEGER,\n visibility TEXT NOT NULL DEFAULT 'private' CHECK(visibility IN ('public', 'unlisted', 'private')),\n lifecycle_status TEXT NOT NULL DEFAULT 'uploading' CHECK(lifecycle_status IN ('uploading', 'ready', 'failed', 'archived', 'deleted')),\n mime_type TEXT,\n size_bytes INTEGER NOT NULL DEFAULT 0,\n content_hash TEXT, -- sha256 hex\n canonical_provider TEXT, -- local | b2 | r2\n canonical_key TEXT,\n legacy_ref TEXT UNIQUE, -- legacy:<app>:<kind>:<id> for projected rows; NULL = native v2 object\n metadata TEXT NOT NULL DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n deleted_at DATETIME\n)",
+ "CREATE TABLE media_quota_reservations (\n object_id TEXT PRIMARY KEY,\n app_id TEXT NOT NULL,\n namespace TEXT NOT NULL,\n bytes INTEGER NOT NULL DEFAULT 0,\n expires_at DATETIME NOT NULL, -- the expiry sweep fails the upload after this (unless a multipart session is open)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE media_relationships (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n from_object_id TEXT NOT NULL,\n relation TEXT NOT NULL, -- clip_of | thumbnail_of | derived_from | screenshot_of\n to_object_id TEXT NOT NULL,\n metadata TEXT NOT NULL DEFAULT '{}',\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(from_object_id, relation, to_object_id)\n)",
+ "CREATE TABLE media_settings (\n key TEXT PRIMARY KEY,\n value TEXT,\n description TEXT DEFAULT '',\n type TEXT DEFAULT 'string',\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)",
+ "CREATE TABLE media_tier_decisions (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n decided_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),\n vod_id INTEGER NOT NULL,\n app_id TEXT,\n object_id TEXT,\n action TEXT NOT NULL CHECK(action IN ('promote', 'demote')),\n from_provider TEXT,\n to_provider TEXT,\n outcome TEXT NOT NULL CHECK(outcome IN ('done', 'already', 'refused', 'failed')),\n trigger TEXT NOT NULL, -- sweep | admin | drill | clip | manual\n reason TEXT NOT NULL,\n inputs TEXT NOT NULL DEFAULT '{}', -- JSON: view_count, last_accessed_at, storage_provider, is_recording, held, file_size, created_at\n thresholds TEXT NOT NULL DEFAULT '{}',-- JSON: the R2 policy in force, each value with its source (default | setting)\n error TEXT\n)",
+ "CREATE TABLE media_upload_parts (\n upload_id TEXT NOT NULL,\n part_number INTEGER NOT NULL, -- 1-based\n size_bytes INTEGER NOT NULL,\n sha256 TEXT NOT NULL,\n received_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (upload_id, part_number)\n)",
+ "CREATE TABLE media_uploads (\n id TEXT PRIMARY KEY, -- mup_<ULID>\n object_id TEXT NOT NULL,\n app_id TEXT NOT NULL,\n part_size INTEGER NOT NULL,\n total_size INTEGER NOT NULL,\n parts_expected INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completing', 'completed', 'aborted', 'expired')),\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n expires_at DATETIME NOT NULL,\n completed_at DATETIME\n)",
+ "CREATE TABLE media_variants (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n object_id TEXT NOT NULL, -- the source object\n variant_name TEXT NOT NULL, -- thumbnail | 720p | waveform | …\n derived_object_id TEXT NOT NULL,\n recipe TEXT, -- how it was made (name@version)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n UNIQUE(object_id, variant_name)\n)",
+ "CREATE TABLE media_verifications (\n object_id TEXT PRIMARY KEY,\n verified_at TEXT NOT NULL, -- ISO-8601 with ms (orders the rotation)\n status TEXT NOT NULL CHECK(status IN ('good', 'no_good_copy', 'unverifiable')),\n good_providers TEXT, -- comma list of providers holding a good copy\n detail TEXT NOT NULL DEFAULT '{}', -- per-location verdicts and any re-upload\n run_id INTEGER\n)",
+ "CREATE TABLE media_verify_runs (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n objects_checked INTEGER NOT NULL DEFAULT 0,\n locations_checked INTEGER NOT NULL DEFAULT 0,\n good INTEGER NOT NULL DEFAULT 0,\n no_good_copy INTEGER NOT NULL DEFAULT 0,\n unverifiable INTEGER NOT NULL DEFAULT 0,\n reuploaded INTEGER NOT NULL DEFAULT 0,\n reupload_failed INTEGER NOT NULL DEFAULT 0,\n no_good_copy_total INTEGER, -- ready objects with no good copy anywhere, after this run\n error TEXT\n)",
+ "CREATE TABLE paste_comments (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n paste_id INTEGER NOT NULL,\n user_id INTEGER,\n parent_id INTEGER,\n anon_name TEXT,\n message TEXT NOT NULL,\n ip_address TEXT,\n is_deleted INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE,\n FOREIGN KEY (parent_id) REFERENCES paste_comments(id) ON DELETE CASCADE\n)",
+ "CREATE TABLE paste_likes (\n paste_id INTEGER NOT NULL,\n user_id INTEGER NOT NULL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (paste_id, user_id),\n FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE\n)",
+ "CREATE TABLE pastes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n app_id TEXT NOT NULL DEFAULT 'live',\n slug TEXT UNIQUE NOT NULL,\n user_id INTEGER,\n type TEXT 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 DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),\n stream_id INTEGER,\n screenshot_path TEXT,\n metadata TEXT,\n burn_after_read INTEGER DEFAULT 0,\n forked_from INTEGER,\n pinned INTEGER DEFAULT 0,\n views INTEGER DEFAULT 0,\n copies INTEGER DEFAULT 0,\n likes INTEGER DEFAULT 0,\n is_nsfw INTEGER DEFAULT 0,\n ip_address TEXT,\n ai_summary TEXT, -- kept for import; Live owns AI generation\n ai_tags TEXT,\n ai_analyzed_at DATETIME,\n object_id TEXT, -- media_objects.id of the screenshot/avatar bytes (text pastes have none)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, unique_views INTEGER DEFAULT 0,\n FOREIGN KEY (forked_from) REFERENCES pastes(id) ON DELETE SET NULL\n)",
+ "CREATE TABLE vods (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n app_id TEXT NOT NULL DEFAULT 'live',\n stream_id INTEGER,\n stream_key TEXT,\n managed_stream_id INTEGER, -- owning app's stream \"slot\" (list filter)\n user_id INTEGER,\n title TEXT,\n description TEXT DEFAULT '',\n file_path TEXT,\n thumbnail_url TEXT,\n master_file_path TEXT,\n file_size INTEGER DEFAULT 0,\n duration_seconds INTEGER DEFAULT 0,\n probe_duration_seconds REAL DEFAULT 0,\n duration_source TEXT, -- probe | remux | unknown: where duration_seconds came from (NULL: before it was recorded)\n probe_format_json TEXT DEFAULT '',\n health_status TEXT DEFAULT 'unknown',\n health_score INTEGER DEFAULT 0,\n health_issues_json TEXT DEFAULT '[]',\n last_health_scan_at DATETIME,\n quarantined_at DATETIME,\n is_public INTEGER DEFAULT 1,\n visibility TEXT DEFAULT 'public',\n is_recording INTEGER DEFAULT 0,\n clips_only INTEGER DEFAULT 0,\n view_count INTEGER DEFAULT 0,\n storage_tier TEXT DEFAULT 'hot', -- legacy column (pre object-store); kept for import\n storage_provider TEXT DEFAULT 'local',\n storage_key TEXT,\n last_accessed_at DATETIME,\n ai_overview TEXT, -- kept for import; Live owns AI generation\n ai_transcript TEXT,\n ai_analyzed_at DATETIME,\n meta_json TEXT DEFAULT '{}',\n object_id TEXT, -- media_objects.id (Wave 4 object model)\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n, unique_views INTEGER DEFAULT 0)",
+ "CREATE INDEX idx_assets_app_kind ON assets(app_id, kind, created_at DESC)",
+ "CREATE UNIQUE INDEX idx_assets_identity ON assets(app_id, kind, name, channel_username)",
+ "CREATE INDEX idx_clips_app ON clips(app_id, created_at DESC)",
+ "CREATE INDEX idx_clips_object ON clips(object_id)",
+ "CREATE INDEX idx_clips_stream ON clips(stream_id)",
+ "CREATE INDEX idx_clips_vod ON clips(vod_id)",
+ "CREATE INDEX idx_content_views_lookup ON content_views(content_type, content_id)",
+ "CREATE INDEX idx_content_visits_item ON content_visits(content_type, content_id)",
+ "CREATE INDEX idx_content_visits_last ON content_visits(last_at)",
+ "CREATE INDEX idx_files_app ON files(app_id, created_at DESC)",
+ "CREATE INDEX idx_media_holds_object ON media_holds(object_id, released_at)",
+ "CREATE INDEX idx_media_jobs_app ON media_jobs(app_id, id)",
+ "CREATE UNIQUE INDEX idx_media_jobs_idem ON media_jobs(app_id, idempotency_key) WHERE idempotency_key IS NOT NULL",
+ "CREATE INDEX idx_media_jobs_object ON media_jobs(object_id, job_type, status)",
+ "CREATE INDEX idx_media_jobs_status ON media_jobs(status, job_type)",
+ "CREATE INDEX idx_media_namespaces_app ON media_namespaces(app_id, namespace)",
+ "CREATE INDEX idx_media_objects_app ON media_objects(app_id, id)",
+ "CREATE INDEX idx_media_objects_kind ON media_objects(app_id, kind)",
+ "CREATE INDEX idx_media_objects_lifecycle ON media_objects(lifecycle_status)",
+ "CREATE INDEX idx_media_objects_namespace ON media_objects(app_id, namespace, lifecycle_status)",
+ "CREATE INDEX idx_media_objects_owner ON media_objects(owner_subject)",
+ "CREATE INDEX idx_media_quota_reservations_expiry ON media_quota_reservations(expires_at)",
+ "CREATE INDEX idx_media_quota_reservations_ns ON media_quota_reservations(app_id, namespace)",
+ "CREATE INDEX idx_media_relationships_to ON media_relationships(to_object_id, relation)",
+ "CREATE INDEX idx_media_tier_decisions_app ON media_tier_decisions(app_id, id)",
+ "CREATE INDEX idx_media_tier_decisions_vod ON media_tier_decisions(vod_id, id)",
+ "CREATE INDEX idx_media_uploads_expiry ON media_uploads(status, expires_at)",
+ "CREATE INDEX idx_media_uploads_object ON media_uploads(object_id, status)",
+ "CREATE INDEX idx_media_verifications_at ON media_verifications(verified_at)",
+ "CREATE INDEX idx_media_verifications_status ON media_verifications(status)",
+ "CREATE INDEX idx_paste_comments_ip ON paste_comments(ip_address)",
+ "CREATE INDEX idx_paste_comments_paste ON paste_comments(paste_id)",
+ "CREATE INDEX idx_pastes_app ON pastes(app_id, created_at DESC)",
+ "CREATE INDEX idx_pastes_slug ON pastes(slug)",
+ "CREATE INDEX idx_pastes_user ON pastes(user_id)",
+ "CREATE INDEX idx_pastes_visibility ON pastes(visibility)",
+ "CREATE INDEX idx_vods_app ON vods(app_id, created_at DESC)",
+ "CREATE INDEX idx_vods_provider ON vods(storage_provider)",
+ "CREATE INDEX idx_vods_recording ON vods(is_recording)",
+ "CREATE TRIGGER trg_clips_hold_guard_v2 BEFORE DELETE ON clips\n WHEN (OLD.object_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.object_id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.object_id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.object_id)))) OR (OLD.vod_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h JOIN vods v ON v.object_id = h.object_id WHERE v.id = OLD.vod_id AND h.released_at IS NULL))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",
+ "CREATE TRIGGER trg_clips_object_deleted AFTER DELETE ON clips\n WHEN OLD.object_id IS NOT NULL\n BEGIN\n UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP),\n updated_at = CURRENT_TIMESTAMP\n WHERE id = OLD.object_id AND lifecycle_status != 'deleted';\n END",
+ "CREATE TRIGGER trg_files_hold_guard_v2 BEFORE DELETE ON files\n WHEN (OLD.object_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.object_id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.object_id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.object_id))))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",
+ "CREATE TRIGGER trg_files_object_deleted AFTER DELETE ON files\n WHEN OLD.object_id IS NOT NULL\n BEGIN\n UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP),\n updated_at = CURRENT_TIMESTAMP\n WHERE id = OLD.object_id AND lifecycle_status != 'deleted';\n END",
+ "CREATE TRIGGER trg_media_objects_deleted_event AFTER UPDATE OF lifecycle_status ON media_objects\n WHEN NEW.lifecycle_status = 'deleted' AND OLD.lifecycle_status != 'deleted'\n BEGIN INSERT INTO media_object_changes (object_id, change, previous_visibility) VALUES (NEW.id, 'deleted', OLD.visibility); END",
+ "CREATE TRIGGER trg_media_objects_hold_delete_v2 BEFORE DELETE ON media_objects\n WHEN EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.id)))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",
+ "CREATE TRIGGER trg_media_objects_hold_guard_v2 BEFORE UPDATE OF lifecycle_status ON media_objects\n WHEN NEW.lifecycle_status = 'deleted' AND OLD.lifecycle_status != 'deleted' AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.id)))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",
+ "CREATE TRIGGER trg_media_objects_visibility_event AFTER UPDATE OF visibility ON media_objects\n WHEN OLD.visibility IS NOT NEW.visibility AND NEW.lifecycle_status != 'deleted'\n BEGIN INSERT INTO media_object_changes (object_id, change, previous_visibility, visibility) VALUES (NEW.id, 'visibility_changed', OLD.visibility, NEW.visibility); END",
+ "CREATE TRIGGER trg_pastes_hold_guard_v2 BEFORE DELETE ON pastes\n WHEN (OLD.object_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.object_id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.object_id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.object_id))))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",
+ "CREATE TRIGGER trg_pastes_object_deleted AFTER DELETE ON pastes\n WHEN OLD.object_id IS NOT NULL\n BEGIN\n UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP),\n updated_at = CURRENT_TIMESTAMP\n WHERE id = OLD.object_id AND lifecycle_status != 'deleted';\n END",
+ "CREATE TRIGGER trg_vods_hold_guard_v2 BEFORE DELETE ON vods\n WHEN (OLD.object_id IS NOT NULL AND EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = OLD.object_id\n OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = OLD.object_id AND r.relation = 'clip_of')\n OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = OLD.object_id))))\n BEGIN SELECT RAISE(ABORT, 'media object is under a retention hold'); END",
+ "CREATE TRIGGER trg_vods_object_deleted AFTER DELETE ON vods\n WHEN OLD.object_id IS NOT NULL\n BEGIN\n UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP),\n updated_at = CURRENT_TIMESTAMP\n WHERE id = OLD.object_id AND lifecycle_status != 'deleted';\n END"
+ ],
+ "ledger": {},
+ "lazy": [
+ "CREATE TABLE IF NOT EXISTS search_doc_pushes ( kind TEXT NOT NULL, media_id INTEGER NOT NULL, hash TEXT NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (kind, media_id) )"
+ ],
+ "statements": [
+ "DELETE FROM assets WHERE id = ?",
+ "DELETE FROM clips WHERE id = ?",
+ "DELETE FROM content_views WHERE content_type = 'vod' AND content_id = ?",
+ "DELETE FROM content_visits WHERE last_at < datetime('now', ?)",
+ "DELETE FROM files WHERE key = ?",
+ "DELETE FROM media_locations WHERE id = ?",
+ "DELETE FROM media_object_changes",
+ "DELETE FROM media_object_changes WHERE id = ?",
+ "DELETE FROM media_objects WHERE id = ?",
+ "DELETE FROM media_quota_reservations WHERE object_id = ?",
+ "DELETE FROM media_settings WHERE key = ?",
+ "DELETE FROM media_upload_parts WHERE upload_id = ?",
+ "DELETE FROM paste_likes WHERE paste_id = ? AND user_id = ?",
+ "DELETE FROM pastes WHERE id = ?",
+ "DELETE FROM vods WHERE id = ?",
+ "INSERT INTO apps (app_id, name, api_key_hash, quota_bytes) VALUES (?, ?, '', ?)",
+ "INSERT INTO apps (app_id, name, api_key_hash, webhook_url, webhook_secret, allowed_origins, quota_bytes) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(app_id) DO UPDATE SET name = excluded.name, api_key_hash = excluded.api_key_hash, webhook_url = excluded.webhook_url, webhook_secret = excluded.webhook_secret, allowed_origins = excluded.allowed_origins, quota_bytes = excluded.quota_bytes",
+ "INSERT INTO assets (app_id, kind, name, file_path, mime, user_id, username, channel_username, duration_seconds, meta_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(app_id, kind, name, channel_username) DO UPDATE SET file_path = excluded.file_path, mime = excluded.mime, user_id = excluded.user_id, username = excluded.username, duration_seconds = excluded.duration_seconds, meta_json = excluded.meta_json",
+ "INSERT INTO clips (app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO files (key, app_id, user_id, original_name, size, mime, sha256) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO media_holds (object_id, kind, reason, created_by, note) VALUES (?, ?, ?, ?, ?)",
+ "INSERT INTO media_invariant_violations (object_id, level, size_bytes, threshold_bytes) VALUES (?, ?, ?, ?)",
+ "INSERT INTO media_locations (object_id, provider, bucket, key, storage_class, state, checksum, size_bytes, verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, owner_app, owner_user_id, visibility, lifecycle_status, mime_type, size_bytes, content_hash, canonical_provider, canonical_key, legacy_ref, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, 'private', 'ready', ?, ?, ?, 'local', ?, NULL, ?)",
+ "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, owner_app, owner_user_id, visibility, lifecycle_status, mime_type, size_bytes, content_hash, canonical_provider, canonical_key, legacy_ref, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))",
+ "INSERT INTO media_quota_reservations (object_id, app_id, namespace, bytes, expires_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(object_id) DO UPDATE SET bytes = excluded.bytes, namespace = excluded.namespace, expires_at = MAX(expires_at, excluded.expires_at), updated_at = CURRENT_TIMESTAMP",
+ "INSERT INTO media_relationships (from_object_id, relation, to_object_id, metadata) VALUES (?, ?, ?, ?) ON CONFLICT(from_object_id, relation, to_object_id) DO UPDATE SET metadata = excluded.metadata",
+ "INSERT INTO media_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ "INSERT INTO media_settings (key, value, description, type) VALUES (?, ?, 'Last object backfill report', 'json') ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
+ "INSERT INTO media_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
+ "INSERT INTO media_tier_decisions (vod_id, app_id, object_id, action, from_provider, to_provider, outcome, trigger, reason, inputs, thresholds, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO media_upload_parts (upload_id, part_number, size_bytes, sha256) VALUES (?, ?, ?, ?) ON CONFLICT(upload_id, part_number) DO UPDATE SET size_bytes = excluded.size_bytes, sha256 = excluded.sha256, received_at = CURRENT_TIMESTAMP",
+ "INSERT INTO media_uploads (id, object_id, app_id, part_size, total_size, parts_expected, expires_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now', ?))",
+ "INSERT INTO media_variants (object_id, variant_name, derived_object_id, recipe) VALUES (?, ?, ?, ?) ON CONFLICT(object_id, variant_name) DO UPDATE SET derived_object_id = excluded.derived_object_id, recipe = excluded.recipe",
+ "INSERT INTO media_verifications (object_id, verified_at, status, good_providers, detail, run_id) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(object_id) DO UPDATE SET verified_at = excluded.verified_at, status = excluded.status, good_providers = excluded.good_providers, detail = excluded.detail, run_id = excluded.run_id",
+ "INSERT INTO media_verify_runs (started_at) VALUES (?)",
+ "INSERT INTO paste_comments (paste_id, user_id, parent_id, anon_name, message, ip_address) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, screenshot_path, metadata, ip_address) VALUES ('network', ?, ?, 'screenshot', ?, '', 'text', 'unlisted', ?, ?, NULL)",
+ "INSERT INTO search_doc_pushes (kind, media_id, hash, revision, deleted, pushed_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(kind, media_id) DO UPDATE SET hash = excluded.hash, revision = excluded.revision, deleted = excluded.deleted, pushed_at = excluded.pushed_at",
+ "INSERT INTO vods (app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public, visibility, clips_only) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT OR IGNORE INTO apps (app_id, name, api_key_hash, quota_bytes, project_id, env) VALUES (?, ?, '', ?, ?, ?)",
+ "INSERT OR IGNORE INTO content_visits (content_type, content_id, visitor) VALUES (?, ?, ?)",
+ "INSERT OR IGNORE INTO content_visits (content_type, content_id, visitor, first_at, last_at, visits) VALUES (?, ?, ?, ?, ?, 1)",
+ "INSERT OR IGNORE INTO media_namespaces (namespace, app_id, parent, owner) VALUES (?, ?, ?, ?)",
+ "INSERT OR IGNORE INTO media_namespaces (namespace, app_id, parent, owner) VALUES (?, ?, NULL, ?)",
+ "INSERT OR IGNORE INTO media_quota_reservations (object_id, app_id, namespace, bytes, expires_at) SELECT id, app_id, namespace, size_bytes, datetime('now', ?) FROM media_objects WHERE lifecycle_status = 'uploading' AND legacy_ref IS NULL",
+ "INSERT OR IGNORE INTO media_settings (key, value, description, type) VALUES (?, ?, ?, ?)",
+ "INSERT OR IGNORE INTO paste_likes (paste_id, user_id) VALUES (?, ?)",
+ "INSERT OR REPLACE INTO media_settings (key, value, description, type) VALUES ('view_hash_secret', ?, 'Internal: salt for hashed visitor ids (do not share)', 'string')",
+ "INSERT OR REPLACE INTO media_settings (key, value, description, type) VALUES ('views_migrated_v2', '1', 'Internal: legacy content_views imported', 'boolean')",
+ "INSERT OR REPLACE INTO media_settings (key, value, type) VALUES ('namespaces.reservations_seeded', ?, 'string')",
+ "SELECT 'vod' k, id ref, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL slug, NULL type, NULL content, NULL language FROM vods WHERE is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0 UNION ALL SELECT 'clip', id, title, description, ai_overview, thumbnail_url, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM clips WHERE COALESCE(is_public,1) = 1 UNION ALL SELECT 'paste', id, title, NULL, NULL, NULL, NULL, created_at, app_id, slug, type, substr(COALESCE(content,''),1,300), language FROM pastes WHERE visibility = 'public' UNION ALL SELECT 'asset', id, name, username, channel_username, kind, duration_seconds, created_at, app_id, NULL, NULL, NULL, NULL FROM assets ORDER BY created_at DESC LIMIT 48 OFFSET 0",
+ "SELECT (SELECT COUNT(*) FROM vods WHERE is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0) + (SELECT COUNT(*) FROM clips WHERE COALESCE(is_public,1) = 1) + (SELECT COUNT(*) FROM pastes WHERE visibility = 'public') + (SELECT COUNT(*) FROM assets) c",
+ "SELECT * FROM apps",
+ "SELECT * FROM apps WHERE api_key_hash = ?",
+ "SELECT * FROM apps WHERE app_id = ?",
+ "SELECT * FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?",
+ "SELECT * FROM assets WHERE id = ?",
+ "SELECT * FROM assets WHERE kind = 'emote' ORDER BY created_at DESC LIMIT 48 OFFSET 0",
+ "SELECT * FROM assets WHERE kind = 'sound' ORDER BY created_at DESC LIMIT 48 OFFSET 0",
+ "SELECT * FROM clips WHERE COALESCE(is_public,1) = 1 ORDER BY created_at DESC LIMIT 48 OFFSET 0",
+ "SELECT * FROM clips WHERE id = ?",
+ "SELECT * FROM files WHERE app_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
+ "SELECT * FROM files WHERE app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL) ORDER BY created_at DESC LIMIT 48 OFFSET 0",
+ "SELECT * FROM files WHERE key = ?",
+ "SELECT * FROM files WHERE key = ? AND app_id = ?",
+ "SELECT * FROM media_holds WHERE id = ?",
+ "SELECT * FROM media_holds WHERE id = ? AND object_id = ?",
+ "SELECT * FROM media_invariant_violations WHERE object_id = ?",
+ "SELECT * FROM media_jobs WHERE app_id = ? AND idempotency_key = ?",
+ "SELECT * FROM media_jobs WHERE app_id = ? AND job_type = ? AND request_hash = ? AND status IN ('queued', 'running') ORDER BY id DESC LIMIT 1",
+ "SELECT * FROM media_jobs WHERE id = ?",
+ "SELECT * FROM media_locations",
+ "SELECT * FROM media_locations WHERE object_id = ? AND provider = 'local'",
+ "SELECT * FROM media_locations WHERE object_id = ? AND provider = ?",
+ "SELECT * FROM media_locations WHERE object_id = ? ORDER BY id",
+ "SELECT * FROM media_namespaces WHERE app_id = ? ORDER BY namespace",
+ "SELECT * FROM media_namespaces WHERE namespace = ?",
+ "SELECT * FROM media_object_changes ORDER BY id LIMIT ?",
+ "SELECT * FROM media_objects WHERE app_id = ? AND lifecycle_status != 'deleted' ORDER BY id DESC LIMIT ?",
+ "SELECT * FROM media_objects WHERE id = ?",
+ "SELECT * FROM media_objects WHERE legacy_ref = ?",
+ "SELECT * FROM media_objects WHERE lifecycle_status = 'deleted' AND legacy_ref IS NULL AND deleted_at <= datetime('now', ?)",
+ "SELECT * FROM media_quota_reservations WHERE object_id = ?",
+ "SELECT * FROM media_tier_decisions WHERE app_id = ? ORDER BY id DESC LIMIT 20",
+ "SELECT * FROM media_uploads WHERE id = ?",
+ "SELECT * FROM media_uploads WHERE object_id = ? AND status IN ('active', 'completing') ORDER BY created_at DESC LIMIT 1",
+ "SELECT * FROM media_variants WHERE object_id = ? AND variant_name = ?",
+ "SELECT * FROM media_verify_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",
+ "SELECT * FROM paste_comments WHERE id = ?",
+ "SELECT * FROM paste_comments WHERE ip_address = ? AND created_at > datetime('now', '-' || ? || ' seconds') ORDER BY created_at DESC",
+ "SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC",
+ "SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?",
+ "SELECT * FROM pastes WHERE id = ?",
+ "SELECT * FROM pastes WHERE slug = ?",
+ "SELECT * FROM pastes WHERE visibility = 'public' AND COALESCE(type,'paste') <> 'screenshot' ORDER BY created_at DESC LIMIT 48 OFFSET 0",
+ "SELECT * FROM pastes WHERE visibility = 'public' AND type = 'screenshot' ORDER BY created_at DESC LIMIT 48 OFFSET 0",
+ "SELECT * FROM vods WHERE COALESCE(is_recording, 0) = 0 AND (health_status IS NULL OR health_status NOT IN ('corrupt','zero_byte','missing_file')) AND (last_health_scan_at IS NULL OR last_health_scan_at <= datetime('now', ?)) ORDER BY (last_health_scan_at IS NULL) DESC, last_health_scan_at ASC LIMIT ?",
+ "SELECT * FROM vods WHERE id = ?",
+ "SELECT * FROM vods WHERE id = ? AND app_id = ?",
+ "SELECT * FROM vods WHERE is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0 ORDER BY created_at DESC LIMIT 48 OFFSET 0",
+ "SELECT * FROM vods WHERE quarantined_at IS NOT NULL AND quarantined_at <= datetime('now', ?) AND health_status IN ('corrupt','zero_byte','missing_file') AND COALESCE(is_recording, 0) = 0 ORDER BY quarantined_at ASC LIMIT ?",
+ "SELECT * FROM vods WHERE storage_key LIKE ?",
+ "SELECT *, COALESCE(duration_seconds, probe_duration_seconds, 0) AS duration_seconds FROM vods WHERE id = ?",
+ "SELECT 1 AS x FROM media_settings WHERE key = 'namespaces.reservations_seeded'",
+ "SELECT 1 AS x FROM pastes WHERE id = ?",
+ "SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?",
+ "SELECT 1 FROM paste_likes WHERE paste_id = ? AND user_id = ?",
+ "SELECT 1 FROM pastes WHERE slug = ?",
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'event_outbox'",
+ "SELECT COALESCE(SUM(size), 0) AS bytes FROM files WHERE app_id = ?",
+ "SELECT COALESCE(owner_app, app_id) AS owner_app, owner_user_id, count(*) AS objects FROM media_objects WHERE owner_subject IS NULL AND owner_user_id IS NOT NULL GROUP BY COALESCE(owner_app, app_id), owner_user_id ORDER BY 1, 2",
+ "SELECT COALESCE(views,0) AS view_count, COALESCE(unique_views,0) AS unique_views FROM pastes WHERE id = ?",
+ "SELECT COUNT(*) AS c FROM vods WHERE app_id = ? AND COALESCE(storage_provider, 'local') = 'local' AND COALESCE(is_recording, 0) = 0 AND created_at <= datetime('now', ?) AND COALESCE(view_count, 0) <= ? AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))",
+ "SELECT COUNT(*) AS count FROM clips WHERE app_id = ?",
+ "SELECT COUNT(*) AS count, COALESCE(SUM(file_size), 0) AS bytes, COALESCE(MIN(created_at), '') AS oldest, COALESCE(MAX(created_at), '') AS newest FROM vods WHERE app_id = ?",
+ "SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes FROM files WHERE app_id = ?",
+ "SELECT COUNT(*) AS count, SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots FROM pastes WHERE app_id = ?",
+ "SELECT COUNT(*) AS n FROM apps",
+ "SELECT COUNT(*) AS n FROM clips c WHERE c.vod_id IN (SELECT id FROM vods WHERE object_id = @o) OR c.object_id IN (SELECT from_object_id FROM media_relationships WHERE to_object_id = @o AND relation = 'clip_of')",
+ "SELECT COUNT(*) AS n FROM media_jobs WHERE app_id = ? AND status IN ('queued', 'running')",
+ "SELECT COUNT(*) AS n FROM media_namespaces WHERE app_id = ? AND parent IS NOT NULL",
+ "SELECT COUNT(*) AS n FROM media_uploads WHERE status IN ('active', 'completing')",
+ "SELECT COUNT(*) AS n FROM media_verifications",
+ "SELECT COUNT(*) AS n, COALESCE(SUM(o.size_bytes), 0) AS b FROM media_objects o WHERE o.app_id = @app AND o.legacy_ref IS NULL AND (o.lifecycle_status = 'ready') AND o.id != @ex",
+ "SELECT COUNT(*) AS n, COALESCE(SUM(r.bytes), 0) AS b FROM media_quota_reservations r JOIN media_objects o ON o.id = r.object_id AND o.lifecycle_status = 'uploading' WHERE r.app_id = @app AND r.object_id != @ex",
+ "SELECT COUNT(*) AS n, COALESCE(SUM(size), 0) AS b FROM files WHERE app_id = ?",
+ "SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b FROM media_upload_parts WHERE upload_id = ?",
+ "SELECT COUNT(*) AS total, SUM(CASE WHEN type = 'paste' THEN 1 ELSE 0 END) AS textPastes, SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots, SUM(CASE WHEN forked_from IS NOT NULL THEN 1 ELSE 0 END) AS forks, COALESCE(SUM(views), 0) AS totalViews, COALESCE(SUM(copies), 0) AS totalCopies, COALESCE(SUM(likes), 0) AS totalLikes FROM pastes WHERE app_id = ?",
+ "SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND ip_address = ? AND created_at > datetime('now', '-1 day')",
+ "SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND user_id = ? AND created_at > datetime('now', '-1 day')",
+ "SELECT COUNT(*) as count FROM paste_comments WHERE paste_id = ? AND is_deleted = 0",
+ "SELECT COUNT(*) c FROM assets WHERE kind = 'emote'",
+ "SELECT COUNT(*) c FROM assets WHERE kind = 'sound'",
+ "SELECT COUNT(*) c FROM clips WHERE COALESCE(is_public,1) = 1",
+ "SELECT COUNT(*) c FROM files WHERE app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)",
+ "SELECT COUNT(*) c FROM pastes WHERE visibility = 'public' AND COALESCE(type,'paste') <> 'screenshot'",
+ "SELECT COUNT(*) c FROM pastes WHERE visibility = 'public' AND type = 'screenshot'",
+ "SELECT COUNT(*) c FROM vods WHERE is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0",
+ "SELECT COUNT(*) n FROM pastes WHERE app_id = ?",
+ "SELECT COUNT(*) n FROM pastes WHERE app_id = ? AND COALESCE(type,'paste') <> 'screenshot'",
+ "SELECT COUNT(*) n FROM pastes WHERE app_id = ? AND type = 'screenshot'",
+ "SELECT DISTINCT app_id FROM media_objects WHERE kind IN ('vod', 'clip') AND visibility != 'private' AND lifecycle_status = 'ready'",
+ "SELECT DISTINCT app_id FROM vods WHERE COALESCE(is_recording, 0) = 0 AND file_path IS NOT NULL",
+ "SELECT DISTINCT o.app_id, o.namespace FROM media_objects o LEFT JOIN media_namespaces n ON n.namespace = o.namespace WHERE n.namespace IS NULL",
+ "SELECT EXISTS (SELECT 1 FROM media_holds h WHERE h.released_at IS NULL AND (h.object_id = @id OR h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = @id AND r.relation = 'clip_of') OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = @id))) AS held",
+ "SELECT SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) AS localCount, SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) AS b2Count, SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) AS r2Count, SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN file_size ELSE 0 END) AS localBytes, SUM(CASE WHEN storage_provider = 'b2' THEN file_size ELSE 0 END) AS b2Bytes, SUM(CASE WHEN storage_provider = 'r2' THEN file_size ELSE 0 END) AS r2Bytes FROM vods WHERE app_id = ?",
+ "SELECT SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount, SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count, SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count FROM clips",
+ "SELECT SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount, SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count, SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count, SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN file_size ELSE 0 END) as localBytes, SUM(CASE WHEN storage_provider = 'b2' THEN file_size ELSE 0 END) as b2Bytes, SUM(CASE WHEN storage_provider = 'r2' THEN file_size ELSE 0 END) as r2Bytes FROM vods",
+ "SELECT action, outcome, COUNT(*) AS n FROM media_tier_decisions WHERE app_id = ? AND decided_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day') GROUP BY action, outcome",
+ "SELECT action, outcome, COUNT(*) AS n FROM media_tier_decisions WHERE decided_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day') GROUP BY action, outcome",
+ "SELECT app_id FROM apps ORDER BY app_id",
+ "SELECT app_id FROM apps WHERE api_key_hash = ?",
+ "SELECT app_id FROM apps WHERE env = 'sandbox'",
+ "SELECT app_id, count(*) AS objects, sum(owner_subject IS NOT NULL) AS already_set, sum(owner_subject IS NULL AND owner_user_id IS NOT NULL) AS missing, sum(owner_subject IS NULL AND owner_user_id IS NULL) AS no_owner FROM media_objects GROUP BY app_id ORDER BY app_id",
+ "SELECT app_id, name, webhook_url, allowed_origins, quota_bytes, created_at FROM apps ORDER BY app_id",
+ "SELECT app_id, namespace, lifecycle_status AS status, COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b FROM media_objects WHERE owner_subject = ? GROUP BY app_id, namespace, lifecycle_status ORDER BY app_id, namespace",
+ "SELECT content_hash, metadata, lifecycle_status FROM media_objects WHERE id = ?",
+ "SELECT content_type, content_id, ip, created_at FROM content_views",
+ "SELECT created_at FROM pastes WHERE app_id = ? AND ip_address = ? ORDER BY created_at DESC LIMIT 1",
+ "SELECT created_at FROM pastes WHERE app_id = ? AND user_id = ? ORDER BY created_at DESC LIMIT 1",
+ "SELECT derived_object_id FROM media_variants WHERE object_id = ? AND variant_name = 'thumbnail'",
+ "SELECT duration_seconds, duration_source FROM vods WHERE id = ?",
+ "SELECT env FROM apps WHERE app_id = ?",
+ "SELECT expires_at FROM media_quota_reservations WHERE object_id = ?",
+ "SELECT file_path FROM assets WHERE app_id = ? AND kind = ? AND name = ? AND channel_username = ?",
+ "SELECT file_path FROM assets WHERE file_path IS NOT NULL",
+ "SELECT file_path FROM vods WHERE app_id = ? AND managed_stream_id = ? AND is_recording = 1 ORDER BY id DESC LIMIT 1",
+ "SELECT file_path, master_file_path FROM vods WHERE COALESCE(is_recording,0)=1 OR quarantined_at IS NOT NULL OR health_status IN ('corrupt','zero_byte','needs_review')",
+ "SELECT file_path, thumbnail_url FROM clips",
+ "SELECT finished_at FROM media_verify_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",
+ "SELECT from_object_id, relation, to_object_id FROM media_relationships WHERE from_object_id = ? OR to_object_id = ? ORDER BY id",
+ "SELECT h.* FROM media_holds h JOIN media_objects o ON o.id = h.object_id WHERE h.id = ? AND o.app_id = ?",
+ "SELECT h.*, h.object_id AS inherited_from FROM media_holds h WHERE h.released_at IS NULL AND h.object_id != @id AND ( h.object_id IN (SELECT r.to_object_id FROM media_relationships r WHERE r.from_object_id = @id AND r.relation = 'clip_of') OR h.object_id IN (SELECT v.object_id FROM clips c JOIN vods v ON v.id = c.vod_id WHERE c.object_id = @id)) ORDER BY h.id",
+ "SELECT hash, revision, deleted FROM search_doc_pushes WHERE kind = ? AND media_id = ?",
+ "SELECT id FROM clips WHERE status = 'failed' AND vod_id IS NOT NULL AND COALESCE(cut_attempts, 0) < ? AND (cut_next_at IS NULL OR cut_next_at <= CURRENT_TIMESTAMP) AND created_at >= datetime('now', '-30 days') AND NOT EXISTS (SELECT 1 FROM media_jobs j WHERE j.job_type = 'clip.cut' AND j.app_id = clips.app_id AND CAST(json_extract(j.params, '$.clip_id') AS INTEGER) = clips.id) ORDER BY created_at DESC LIMIT 4",
+ "SELECT id FROM clips WHERE status = 'processing'",
+ "SELECT id FROM media_jobs WHERE id = ? AND status = 'queued'",
+ "SELECT id FROM media_jobs WHERE status IN ('queued', 'running')",
+ "SELECT id FROM media_objects WHERE legacy_ref = ?",
+ "SELECT id FROM media_uploads WHERE status IN ('active', 'completing')",
+ "SELECT id FROM media_uploads WHERE status IN ('active', 'completing') AND expires_at < datetime('now')",
+ "SELECT id FROM vods WHERE COALESCE(storage_provider, 'local') = 'local' AND COALESCE(is_recording, 0) = 0 AND created_at <= datetime('now', '-1 day') ORDER BY (last_accessed_at IS NOT NULL), last_accessed_at ASC, view_count ASC, file_size DESC LIMIT 10",
+ "SELECT id FROM vods WHERE COALESCE(storage_provider, 'local') = 'local' AND COALESCE(is_recording, 0) = 0 AND file_path IS NULL AND created_at <= datetime('now', '-1 hour') AND COALESCE(health_status, 'ok') NOT IN ('missing_file', 'zero_byte')",
+ "SELECT id FROM vods WHERE is_recording = 1",
+ "SELECT id, app_id, COALESCE(owner_app, app_id) AS owner_app, owner_user_id FROM media_objects WHERE owner_subject IS NULL AND owner_user_id IS NOT NULL AND id > ? ORDER BY id LIMIT ?",
+ "SELECT id, app_id, kind, legacy_ref FROM media_objects WHERE id = ?",
+ "SELECT id, app_id, user_id, stream_id, managed_stream_id, title, visibility, is_public, file_path, file_size, storage_provider, storage_key, created_at FROM vods WHERE object_id = ?",
+ "SELECT id, app_id, visibility, is_public, auto_generated, thumbnail_url, created_at FROM clips WHERE COALESCE(status, 'ready') = 'ready' AND COALESCE(is_public, 1) = 1 AND COALESCE(visibility, 'public') = 'public' AND COALESCE(auto_generated, 0) = 0 AND COALESCE(file_path, '') != '' AND app_id != 'live' AND EXISTS (SELECT 1 FROM media_objects ro WHERE ro.id = clips.object_id AND ro.lifecycle_status = 'ready' AND ro.kind IN ('vod', 'clip') AND (CASE WHEN json_valid(ro.metadata) THEN json_type(ro.metadata) END) = 'object' AND EXISTS (SELECT 1 FROM media_locations rl WHERE rl.object_id = ro.id AND rl.state = 'present' AND rl.verified_at IS NOT NULL AND (rl.checksum IS NULL OR ro.content_hash IS NULL OR rl.checksum = ro.content_hash))) ORDER BY created_at DESC LIMIT ?",
+ "SELECT id, app_id, visibility, is_public, thumbnail_url, created_at FROM vods WHERE COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0 AND is_public = 1 AND COALESCE(visibility, 'public') = 'public' AND quarantined_at IS NULL AND COALESCE(health_status, 'ok') NOT IN ('corrupt', 'zero_byte', 'missing_file', 'needs_review') AND file_path IS NOT NULL AND app_id != 'live' AND EXISTS (SELECT 1 FROM media_objects ro WHERE ro.id = vods.object_id AND ro.lifecycle_status = 'ready' AND ro.kind IN ('vod', 'clip') AND (CASE WHEN json_valid(ro.metadata) THEN json_type(ro.metadata) END) = 'object' AND EXISTS (SELECT 1 FROM media_locations rl WHERE rl.object_id = ro.id AND rl.state = 'present' AND rl.verified_at IS NOT NULL AND (rl.checksum IS NULL OR ro.content_hash IS NULL OR rl.checksum = ro.content_hash))) ORDER BY created_at DESC LIMIT ?",
+ "SELECT id, app_id, vod_id, stream_id, user_id, channel_user_id, title, visibility, is_public, file_path, status, created_at FROM clips WHERE object_id = ?",
+ "SELECT id, file_path FROM vods WHERE id = ? AND app_id = ?",
+ "SELECT id, file_path FROM vods WHERE is_recording = 1 ORDER BY id",
+ "SELECT id, file_path FROM vods WHERE storage_tier = 'cold' AND COALESCE(storage_provider, 'local') = 'local'",
+ "SELECT id, file_path, file_size, duration_seconds FROM vods WHERE is_recording = 0 AND is_public = 1 AND COALESCE(duration_seconds, 0) < 3 AND COALESCE(file_size, 0) < 10000000 AND (storage_provider IS NULL OR storage_provider = 'local') AND COALESCE(health_status, 'unknown') IN ('unknown', '')",
+ "SELECT id, file_path, master_file_path, thumbnail_url FROM vods",
+ "SELECT id, finished_at, objects_checked, reuploaded FROM media_verify_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",
+ "SELECT id, job_type, status, error_code, attempts, max_attempts, created_at, finished_at FROM media_jobs WHERE object_id = ? ORDER BY id DESC LIMIT 10",
+ "SELECT id, kind, reason, created_by, created_at FROM media_holds WHERE object_id = ? AND released_at IS NULL ORDER BY id",
+ "SELECT id, lifecycle_status FROM media_objects WHERE id = ?",
+ "SELECT id, lifecycle_status, deleted_at FROM media_objects WHERE legacy_ref = ?",
+ "SELECT id, object_id FROM media_jobs WHERE app_id = ? AND status = 'proposed' AND created_by LIKE ?",
+ "SELECT id, owner_subject, COALESCE(owner_app, app_id) AS owner_app, owner_user_id FROM media_objects WHERE id = ?",
+ "SELECT id, size_bytes, content_hash, lifecycle_status FROM media_objects WHERE id = ?",
+ "SELECT id, slug, app_id, user_id, title, type, visibility, screenshot_path, created_at FROM pastes WHERE object_id = ?",
+ "SELECT id, state, size_bytes FROM media_locations WHERE object_id = ? AND provider = ?",
+ "SELECT id, status FROM media_jobs WHERE app_id = ? AND idempotency_key = ?",
+ "SELECT id, title, created_at, duration_seconds, visibility, is_recording, managed_stream_id, user_id, ai_overview, ai_transcript, ai_analyzed_at, app_id, is_public, clips_only FROM vods WHERE id = ?",
+ "SELECT id, view_count, last_accessed_at FROM vods WHERE COALESCE(storage_provider, 'local') IN ('local', 'b2') AND COALESCE(is_recording, 0) = 0 AND COALESCE(view_count, 0) >= ? AND last_accessed_at IS NOT NULL AND last_accessed_at >= datetime('now', ?) LIMIT ?",
+ "SELECT is_recording FROM vods WHERE id = ?",
+ "SELECT j.* FROM media_jobs j WHERE j.app_id = ? ORDER BY j.id DESC LIMIT ?",
+ "SELECT job_type, status, COUNT(*) AS n FROM media_jobs GROUP BY job_type, status",
+ "SELECT key, app_id FROM files",
+ "SELECT key, app_id, user_id, original_name, size, mime, created_at FROM files WHERE object_id = ?",
+ "SELECT key, state FROM media_locations WHERE id = ?",
+ "SELECT key, value FROM media_settings WHERE key LIKE 'storage_tier.%'",
+ "SELECT l.* FROM media_locations l LEFT JOIN media_objects o ON o.id = l.object_id WHERE o.app_id = ? OR o.id IS NULL",
+ "SELECT l.provider, l.bucket, l.key, l.state, l.verified_at, o.id AS object_id, o.app_id, o.kind, o.legacy_ref, o.lifecycle_status FROM media_locations l JOIN media_objects o ON o.id = l.object_id WHERE o.lifecycle_status != 'deleted' ORDER BY l.id",
+ "SELECT last_at, visits FROM content_visits WHERE content_type = ? AND content_id = ? AND visitor = ?",
+ "SELECT lifecycle_status FROM media_objects WHERE id = ?",
+ "SELECT lifecycle_status, COUNT(*) AS n FROM media_objects GROUP BY lifecycle_status",
+ "SELECT likes FROM pastes WHERE id = ?",
+ "SELECT media_id FROM search_doc_pushes WHERE kind = ? AND deleted = 0",
+ "SELECT namespace, app_id FROM media_namespaces ORDER BY app_id, namespace",
+ "SELECT o.* FROM media_objects o LEFT JOIN media_verifications v ON v.object_id = o.id WHERE o.lifecycle_status = 'ready' ORDER BY (v.verified_at IS NOT NULL), v.verified_at, o.id LIMIT ?",
+ "SELECT o.*, v.id AS violation_id FROM media_invariant_violations v LEFT JOIN media_objects o ON o.id = v.object_id WHERE v.resolved_at IS NULL",
+ "SELECT o.id, o.size_bytes, l.id AS loc_id, l.key AS loc_key FROM media_objects o JOIN media_locations l ON l.object_id = o.id AND l.provider = 'local' AND l.state = 'present' WHERE o.lifecycle_status = 'ready' AND o.content_hash IS NULL",
+ "SELECT object_id FROM vods WHERE id = ?",
+ "SELECT object_id, variant_name, derived_object_id FROM media_variants WHERE object_id = ? OR derived_object_id = ? ORDER BY id",
+ "SELECT part_number, size_bytes, sha256, received_at FROM media_upload_parts WHERE upload_id = ? ORDER BY part_number",
+ "SELECT provider, bucket, key, state, size_bytes, verified_at FROM media_locations WHERE object_id = ? ORDER BY id",
+ "SELECT provider, state, COUNT(*) AS n FROM media_locations GROUP BY provider, state",
+ "SELECT provider, state, checksum, verified_at FROM media_locations WHERE object_id = ? ORDER BY id",
+ "SELECT r.object_id, r.app_id, r.namespace, o.lifecycle_status, o.legacy_ref, o.metadata FROM media_quota_reservations r LEFT JOIN media_objects o ON o.id = r.object_id WHERE r.expires_at < datetime('now') ORDER BY r.expires_at LIMIT 1000",
+ "SELECT r.relation, r.to_object_id FROM media_relationships r JOIN media_objects s ON s.id = r.to_object_id WHERE r.from_object_id = ? AND r.relation IN ('derived_from', 'thumbnail_of', 'clip_of') AND s.owner_subject = ? ORDER BY r.id LIMIT 1",
+ "SELECT screenshot_path FROM pastes WHERE screenshot_path IS NOT NULL",
+ "SELECT size_bytes FROM media_locations WHERE object_id = ? AND provider = 'local' AND state = 'present'",
+ "SELECT status FROM media_jobs WHERE id = ?",
+ "SELECT status FROM media_uploads WHERE id = ?",
+ "SELECT status, COUNT(*) AS n FROM media_jobs GROUP BY status",
+ "SELECT thumbnail_url FROM clips WHERE id = ?",
+ "SELECT thumbnail_url FROM vods WHERE id = ?",
+ "SELECT u.*, (SELECT COUNT(*) FROM media_upload_parts p WHERE p.upload_id = u.id) AS parts_received FROM media_uploads u WHERE status IN ('active', 'completing') ORDER BY created_at",
+ "SELECT u.*, (SELECT COUNT(*) FROM media_upload_parts p WHERE p.upload_id = u.id) AS parts_received, (SELECT COALESCE(SUM(p.size_bytes), 0) FROM media_upload_parts p WHERE p.upload_id = u.id) AS bytes_received, (u.expires_at < datetime('now')) AS expired FROM media_uploads u WHERE u.status IN ('active', 'completing') ORDER BY u.created_at",
+ "SELECT user_id, COUNT(*) AS vodCount, COALESCE(SUM(file_size), 0) AS totalSize FROM vods WHERE app_id = ? GROUP BY user_id ORDER BY totalSize DESC LIMIT 20",
+ "SELECT value FROM media_settings WHERE key = 'namespaces.reservations_seeded'",
+ "SELECT value FROM media_settings WHERE key = 'view_hash_secret'",
+ "SELECT value FROM media_settings WHERE key = 'views_migrated_v2'",
+ "SELECT value FROM media_settings WHERE key = ?",
+ "SELECT value, type FROM media_settings WHERE key = ?",
+ "SELECT variant_name, derived_object_id FROM media_variants WHERE object_id = ? ORDER BY id",
+ "SELECT verified_at, status, detail FROM media_verifications WHERE object_id = ?",
+ "UPDATE clips SET auto_generated = ? WHERE id = ?",
+ "UPDATE clips SET cut_attempts = 0 WHERE id = ?",
+ "UPDATE clips SET file_path = ?, duration_seconds = ?, end_time = ?, status = 'ready', cut_error = NULL, cut_next_at = NULL WHERE id = ?",
+ "UPDATE clips SET status = 'failed', cut_error = ?, cut_next_at = ? WHERE id = ?",
+ "UPDATE clips SET status = 'failed', cut_error = ?, cut_next_at = NULL WHERE id = ?",
+ "UPDATE clips SET status = 'processing', cut_attempts = ?, cut_next_at = NULL WHERE id = ?",
+ "UPDATE clips SET thumbnail_url = '/t/' || replace(thumbnail_url, rtrim(thumbnail_url, replace(thumbnail_url, '/', '')), '') WHERE thumbnail_url LIKE '%/api/thumbnails/%'",
+ "UPDATE clips SET thumbnail_url = ? WHERE id = ?",
+ "UPDATE clips SET title = ? WHERE id = ?",
+ "UPDATE clips SET unique_views = (SELECT COUNT(*) FROM content_visits v WHERE v.content_type = 'clip' AND v.content_id = clips.id)",
+ "UPDATE clips SET view_count = MAX(COALESCE(view_count,0), unique_views)",
+ "UPDATE clips SET visibility = ?, is_public = ? WHERE id = ?",
+ "UPDATE content_visits SET last_at = CURRENT_TIMESTAMP, visits = visits + 1 WHERE content_type = ? AND content_id = ? AND visitor = ?",
+ "UPDATE media_holds SET released_at = CURRENT_TIMESTAMP, released_by = ? WHERE id = ? AND released_at IS NULL",
+ "UPDATE media_invariant_violations SET level = ?, size_bytes = ?, threshold_bytes = ?, last_seen_at = CURRENT_TIMESTAMP, resolved_at = NULL, detected_at = CASE WHEN resolved_at IS NULL THEN detected_at ELSE CURRENT_TIMESTAMP END WHERE id = ?",
+ "UPDATE media_invariant_violations SET resolved_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_jobs SET cancel_requested = 1, decided_by = COALESCE(decided_by, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_jobs SET checkpoint = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_jobs SET checkpoint = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'running' AND lease_token IS ?",
+ "UPDATE media_locations SET bucket = ?, key = ?, storage_class = ?, state = ?, checksum = ?, size_bytes = ?, verified_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_locations SET checksum = ?, size_bytes = ?, verified_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND key = ?",
+ "UPDATE media_locations SET state = 'present', size_bytes = ?, verified_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_locations SET state = ?, size_bytes = ? WHERE object_id = ? AND provider = ?",
+ "UPDATE media_locations SET state = ?, size_bytes = COALESCE(?, size_bytes), verified_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND key = ?",
+ "UPDATE media_namespaces SET used_bytes = ?, used_objects = ?, reserved_bytes = ?, reserved_objects = ?, reconciled_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE namespace = ? AND app_id = ?",
+ "UPDATE media_objects SET content_hash = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND content_hash IS NULL",
+ "UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = CURRENT_TIMESTAMP, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_objects SET lifecycle_status = 'failed', metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND lifecycle_status = 'uploading'",
+ "UPDATE media_objects SET lifecycle_status = 'failed', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND lifecycle_status = ?",
+ "UPDATE media_objects SET lifecycle_status = ? WHERE id = ? AND lifecycle_status = ?",
+ "UPDATE media_objects SET lifecycle_status = ?, deleted_at = NULL, metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_objects SET metadata = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_objects SET namespace = ? WHERE app_id = ? AND namespace = ?",
+ "UPDATE media_objects SET owner_subject = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_subject IS NULL AND COALESCE(owner_app, app_id) = ? AND owner_user_id = ?",
+ "UPDATE media_objects SET owner_subject = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND owner_subject = ? AND COALESCE(owner_app, app_id) = ? AND owner_user_id = ?",
+ "UPDATE media_objects SET size_bytes = ?, content_hash = ? WHERE id = ? AND size_bytes IS ? AND content_hash IS ?",
+ "UPDATE media_objects SET size_bytes = ?, content_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND size_bytes IS ? AND content_hash IS ?",
+ "UPDATE media_quota_reservations SET bytes = ?, updated_at = CURRENT_TIMESTAMP WHERE object_id = ?",
+ "UPDATE media_quota_reservations SET expires_at = MAX(expires_at, ?), updated_at = CURRENT_TIMESTAMP WHERE object_id = ?",
+ "UPDATE media_uploads SET status = 'active', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'completing'",
+ "UPDATE media_uploads SET status = 'completed', completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_uploads SET status = 'completing', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'active'",
+ "UPDATE media_uploads SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IN ('active', 'completing')",
+ "UPDATE media_uploads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE media_verify_runs SET finished_at = ?, objects_checked = ?, locations_checked = ?, good = ?, no_good_copy = ?, unverifiable = ?, reuploaded = ?, reupload_failed = ?, no_good_copy_total = ?, error = ? WHERE id = ?",
+ "UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ "UPDATE pastes SET copies = copies + 1 WHERE slug = ?",
+ "UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?",
+ "UPDATE pastes SET views = COALESCE(views,0) + 1, unique_views = COALESCE(unique_views,0) + 1 WHERE id = ?",
+ "UPDATE pastes SET views = views + 1 WHERE id = ?",
+ "UPDATE vods SET duration_seconds = ?, file_size = ? WHERE id = ?",
+ "UPDATE vods SET duration_seconds = ?, file_size = ?, duration_source = 'probe' WHERE id = ?",
+ "UPDATE vods SET duration_seconds = ?, file_size = ?, probe_duration_seconds = ?, duration_source = ?, last_health_scan_at = datetime('now') WHERE id = ?",
+ "UPDATE vods SET duration_seconds = ?, probe_duration_seconds = ?, duration_source = ? WHERE id = ?",
+ "UPDATE vods SET duration_seconds = ?, probe_duration_seconds = ?, duration_source = ? WHERE id = ? AND COALESCE(duration_seconds, 0) = ? AND COALESCE(duration_source, '') = ? AND COALESCE(is_recording, 0) = 0",
+ "UPDATE vods SET file_path = ?, file_size = ?, is_recording = 1 WHERE id = ?",
+ "UPDATE vods SET file_size = ?, duration_seconds = ? WHERE id = ?",
+ "UPDATE vods SET health_status = 'missing_file', health_issues_json = ?, last_health_scan_at = datetime('now'), quarantined_at = COALESCE(quarantined_at, datetime('now')), is_public = 0 WHERE id = ?",
+ "UPDATE vods SET health_status = 'needs_review', health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",
+ "UPDATE vods SET is_recording = 0, duration_seconds = 0, duration_source = 'unknown', health_status = 'needs_review', health_issues_json = ?, last_health_scan_at = datetime('now') WHERE id = ?",
+ "UPDATE vods SET is_recording = 0, duration_seconds = 0, duration_source = 'unknown', health_status = 'needs_review', health_issues_json = ?, probe_duration_seconds = 0, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",
+ "UPDATE vods SET is_recording = 0, duration_seconds = 0, duration_source = 'unknown', health_status = 'needs_review', health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",
+ "UPDATE vods SET is_recording = 0, duration_seconds = ?, duration_source = ?, health_status = ?, health_issues_json = ?, probe_duration_seconds = ?, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",
+ "UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, quarantined_at = COALESCE(quarantined_at, datetime('now')), is_public = 0, last_health_scan_at = datetime('now') WHERE id = ?",
+ "UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, quarantined_at = datetime('now'), is_public = 0 WHERE id = ?",
+ "UPDATE vods SET is_recording = 1, file_path = ? WHERE id = ?",
+ "UPDATE vods SET last_accessed_at = datetime('now') WHERE id = ?",
+ "UPDATE vods SET master_file_path = ? WHERE id = ?",
+ "UPDATE vods SET master_file_path = NULL WHERE id = ?",
+ "UPDATE vods SET quarantined_at = NULL, health_status = 'ok' WHERE id = ?",
+ "UPDATE vods SET storage_provider = 'b2' WHERE id = ?",
+ "UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?",
+ "UPDATE vods SET storage_provider = 'local' WHERE id = ?",
+ "UPDATE vods SET storage_provider = 'r2', storage_key = ? WHERE id = ?",
+ "UPDATE vods SET thumbnail_url = '/t/' || replace(thumbnail_url, rtrim(thumbnail_url, replace(thumbnail_url, '/', '')), '') WHERE thumbnail_url LIKE '%/api/thumbnails/%'",
+ "UPDATE vods SET thumbnail_url = ? WHERE id = ?",
+ "UPDATE vods SET unique_views = (SELECT COUNT(*) FROM content_visits v WHERE v.content_type = 'vod' AND v.content_id = vods.id)",
+ "UPDATE vods SET view_count = MAX(COALESCE(view_count,0), unique_views)",
+ "UPDATE vods SET visibility = ?, is_public = ? 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,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,28 @@
@@ -0,0 +1,28 @@
+'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:
+ * - N1_SQL_OUT: every SQL text the process prepares or executes on the main database (DB_PATH) is
+ * written there as JSON when it exits;
+ * - the restore-drill sandbox (MEDIA_DRILL: no outbound connection, program, listener or timer) is
+ * kept, but its read-only guard is lifted, so the old client's writes reach their routes.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const cwd = process.cwd();
+const out = process.env.N1_SQL_OUT;
+if (out) {
+ const Database = require(path.join(cwd, 'node_modules', 'better-sqlite3'));
+ const main = path.resolve(process.env.DB_PATH || '');
+ const seen = new Set();
+ const note = (db, sql) => { try { if (path.resolve(db.name) === main) 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 { /* */ } });
+}
+
+const drill = require(path.join(cwd, 'server', 'drill'));
+drill.readOnly = (req, res, next) => next();
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * Media'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.
+ *
+ * Media's clients: its server-rendered pages (/, /browse, /v, /c, /p, /me: an open tab holds the page
+ * N-1 served, the player's media URLs and every link and form in it), the object explorer's script,
+ * and the openvibe-sdk media and objects clients (files v1, Object API v2 and jobs) as that release
+ * installed them, signed in with an app key the way the SDK is.
+ *
+ * A release boots as a real `node server/index.js` in the restore-drill sandbox (MEDIA_DRILL: loopback
+ * only, no outbound connection, program or background job; stored bytes answer 503), with
+ * test/n-1/preload.js lifting the drill's read-only guard so writes reach their routes.
+ */
+const fs = require('fs');
+const net = require('net');
+const path = require('path');
+const { spawn, spawnSync } = require('child_process');
+const { readTree } = require('./harness');
+
+const PRELOAD = path.join(__dirname, 'preload.js');
+const APP_BEARER = 'n1n1n1n1n1n1n1n1n1n1n1n1';
+const STORAGE = ['VOD_PATH:vods', 'CLIPS_PATH:clips', 'PASTES_PATH:pastes', 'FILES_PATH:files', 'OBJECTS_PATH:objects', 'ASSETS_PATH:assets', 'THUMBNAILS_PATH:thumbnails'];
+
+function freePort() {
+ return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
+}
+
+/** A clean environment: the release's storage under dataDir; nothing from the caller's shell but PATH and HOME. */
+function baseEnv(dbPath, dataDir, extra) {
+ const env = { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR || '/tmp', NODE_ENV: 'test', DB_PATH: dbPath, MEDIA_PUBLIC_URL: 'https://openvibe.media', ...extra };
+ for (const s of STORAGE) { const [k, d] = s.split(':'); env[k] = path.join(dataDir, d); fs.mkdirSync(env[k], { recursive: true }); }
+ return env;
+}
+
+const SEED = `
+ console.log = () => {}; console.warn = () => {};
+ const fs = require('fs'); const path = require('path');
+ const db = require('./server/db/database');
+ db.getDb();
+ try { require('./server/views/service').ensureSchema(); } catch (e) { /* older release */ }
+ db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: ${JSON.stringify(APP_BEARER)} });
+ const put = (dir, name) => { const f = path.join(process.env[dir], name); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, 'bytes of ' + name); return f; };
+ db.run("INSERT INTO vods (app_id, user_id, title, file_path, file_size, thumbnail_url, duration_seconds, is_public, visibility) VALUES ('live', 1, 'N-1 VOD', ?, 18, '/t/vod-1-1.jpg', 60, 1, 'public')", [put('VOD_PATH', 'vod-1.mp4')]);
+ db.run("INSERT INTO clips (app_id, vod_id, user_id, title, file_path, duration_seconds, status, is_public, visibility) VALUES ('live', 1, 1, 'N-1 clip', ?, 10, 'ready', 1, 'public')", [put('CLIPS_PATH', 'clip-1.mp4')]);
+ db.run("INSERT INTO pastes (app_id, slug, type, title, content, visibility) VALUES ('live', 'n1paste', 'paste', 'N-1 paste', 'hello from N-1', 'public')");
+ db.run("INSERT INTO files (key, app_id, original_name, size, mime) VALUES ('n1file.txt', 'live', 'n1file.txt', 17, 'text/plain')");
+ put('FILES_PATH', 'live/n1file.txt'); put('THUMBNAILS_PATH', 'vod-1-1.jpg');
+ try { require('./server/objects/backfill').backfill({ onlyMissing: true }); } catch (e) { /* older release */ }
+ db.close();
+`;
+
+module.exports = {
+ service: 'media',
+
+ clientFiles(dir) {
+ return readTree(dir, ['server/public', 'server/me', 'node_modules/openvibe-sdk/src/media.js', 'node_modules/openvibe-sdk/src/objects.js'], ['.js']);
+ },
+ callers: [
+ { name: 'fetch' },
+ { name: 'fetchJson' },
+ { name: 'call', object: true },
+ { name: 'json', object: true },
+ ],
+ forms: true,
+ keep: () => true,
+ crawl: ['/', '/browse', '/v/1', '/c/1', '/p/n1paste', '/me', '/updates'],
+ origins: ['https://openvibe.media'],
+ /** Values for template expressions, first match wins (the rows SEED writes). */
+ samples: [
+ [/app/i, 'live'],
+ [/key/i, 'n1file.txt'],
+ [/upload_?id|uploadId/i, 'n1-upload'],
+ [/object_?id|created\.id|^enc\(id\)$/i, '@object'],
+ [/id\)*$/i, '1'],
+ [/slug/i, 'n1paste'],
+ [/scope/i, ''],
+ [/^n$/, '1'],
+ ],
+
+ sqlDirs: ['server'],
+ ledgerTables: [],
+
+ /** Seeds a database with the release in `dir` (opening it migrates first). */
+ seed({ dir, dbPath, dataDir }) {
+ const r = spawnSync(process.execPath, ['-e', SEED], { cwd: dir, encoding: 'utf8', timeout: 120000, env: baseEnv(dbPath, dataDir, {}) });
+ if (r.status !== 0) throw new Error(`seeding failed:\n${String(r.stderr || '').slice(-2000)}`);
+ },
+
+ /** Boots the release in `dir` → { url, headers(auth), close() }; 'user' is the SDK's app key. */
+ async boot({ dir, dbPath, dataDir, sqlOut = '' }) {
+ const port = await freePort();
+ const child = spawn(process.execPath, ['-r', PRELOAD, 'server/index.js'], {
+ cwd: dir,
+ env: baseEnv(dbPath, dataDir, { MEDIA_DRILL: '1', HOST: '127.0.0.1', PORT: String(port), N1_SQL_OUT: sqlOut }),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let log = '';
+ child.stdout.on('data', (c) => { log = (log + c).slice(-20000); });
+ child.stderr.on('data', (c) => { log = (log + c).slice(-20000); });
+ const exited = new Promise((resolve) => child.on('exit', resolve));
+ const url = `http://127.0.0.1:${port}`;
+ for (let i = 0; ; i++) {
+ if (child.exitCode != null) throw new Error(`the release in ${dir} exited while booting:\n${log.slice(-3000)}`);
+ try { const r = await fetch(`${url}/release.json`); if (r.status === 200) break; } catch { /* not listening yet */ }
+ if (i > 300) { child.kill('SIGKILL'); throw new Error(`the release in ${dir} did not become ready:\n${log.slice(-3000)}`); }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ // The seeded VOD's object (ids are random): the SDK's object calls address it.
+ let object = null;
+ try {
+ const Database = require('better-sqlite3');
+ const d = new Database(dbPath, { readonly: true, fileMustExist: true });
+ try { object = (d.prepare("SELECT id FROM media_objects WHERE kind = 'vod' ORDER BY created_at, id LIMIT 1").get() || {}).id || null; } finally { d.close(); }
+ } catch { /* an older release without media_objects */ }
+ return {
+ url,
+ ids: { object },
+ log: () => log,
+ headers: (auth) => (auth === 'user' ? { authorization: `Bearer ${APP_BEARER}` } : {}),
+ async close() {
+ if (child.exitCode == null) child.kill('SIGTERM');
+ const t = setTimeout(() => { if (child.exitCode == null) child.kill('SIGKILL'); }, 5000);
+ await exited;
+ clearTimeout(t);
+ },
+ };
+ },
+};