openvibers/openvibe.live Commits

Live: the migration era is deleted (SQLite migration and reset scripts, legacy deploy path, better-sqlite3, DB_PATH) (#72)

OP OpenVibers committed 10 Oct 2026 · 1afaf75c4b · 41 files, +113 −2568

Plan T1 "Delete the migration era" for Live. Live has run on PostgreSQL (ov_live) since 2026-10-08 and on the release layout through ovhost since 2026-09-27. PGlite stays for development and tests.

**Deleted:**
- `scripts/migrate-to-postgres.js`;
- `scripts/reset-platform.sh`, which wiped the SQLite database file and is obsolete and dangerous now;
- `deploy/scripts/deploy-legacy.sh`, its simulator test `test/deploy-sim.test.js`, and `deploy/scripts/migrate-to-releases.sh` (the one-time move to the release layout, done 09-27);
- finished cutover runbooks: chat tables 003, emotes 006, PR 21/22, the PostgreSQL slice-1 ADR and runbook.

**Changed:**
- `deploy.sh` runs only ovhost and exits 1 without it. The release systemd unit drops the legacy-layout note.
- `better-sqlite3` and `DB_PATH` are gone (package.json, lockfile, .env.example, docs).
- The N-1 harness seeds and replays through PGlite only.
- Diagnostics stop reporting SQLite file and WAL sizes.
- Comments, README/STATUS, the deploy docs and the in-site documentation fragments describe PostgreSQL.

`ov test`: 115 passed, including the drill, deploy-wrapper, systemd-unit and N-1 tests. Drafted by a Codex job and reviewed here; the lockfile was regenerated and the tests run here.

Changes

ddiff --git a/.env.example b/.env.example
iindex 57c1a910..a271dda6 100644
---- a/.env.example
++++ b/.env.example
@@@ -36,9 +36,7 @@ BASE_URL=http://localhost:3000
JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars
# Database
--DB_PATH=./data/live.db
--# Page analytics only. Defaults to SQLite until its PostgreSQL schema is migrated.
--# ANALYTICS_DRIVER=postgres
++# Production requires both URLs. Development without DATABASE_URL uses PGlite under DATA_DIR.
# DATABASE_URL=postgres://live_runtime:password@localhost:5432/ov_live
# DATABASE_DIRECT_URL=postgres://live_owner:password@localhost:5432/ov_live
# Every file Live writes lives under one data directory (default ./data, relative to the working
@@@ -46,7 +44,7 @@ DB_PATH=./data/live.db
# DATA_DIR=./data
# Restore-drill mode, set only by `ovhost drill live` (OpenVibe.Host docs/restore-drills.md): reads
--# only from a restored copy; refuses to start unless DB_PATH and DATA_DIR are outside the checkout,
++# only from a restored copy; refuses to start unless DATA_DIR is outside the checkout,
# HOST is loopback and PORT is not 3000. Starts no jobs, sockets or listeners but its HTTP port and
# makes no outbound connection (server/drill.js).
# LIVE_DRILL=1
ddiff --git a/OVH.md b/OVH.md
iindex 69d06050..10c52674 100644
---- a/OVH.md
++++ b/OVH.md
@@@ -29,7 +29,7 @@ If you want **one OVH instance that makes the most sense for real OpenVibe.Live
Why:
--- 32 GB RAM is enough for Node, SQLite, WebSockets, chat, thumbnails, clips, and moderate media workload
++- 32 GB RAM is enough for Node, PostgreSQL, WebSockets, chat, thumbnails, clips, and moderate media workload
- 8 vCores is a sane floor for a mixed single-box deployment
- 200 GB NVMe is much more realistic than 50–100 GB once VODs and clips start accumulating
- 2 Gbit/s leaves real headroom for 200 viewers at practical streaming bitrates
@@@ -95,7 +95,7 @@ Relative server-side cost, roughly:
The current codebase stores locally:
--- SQLite database
++- PostgreSQL database
- VODs
- clips
- thumbnails
@@@ -179,7 +179,7 @@ You should reserve space for:
- Ubuntu + packages + logs
- app code
--- SQLite DB
++- PostgreSQL data
- thumbnails, avatars, emotes
- temporary files during clip/VOD work
ddiff --git a/README.md b/README.md
iindex eaab687b..760112a5 100644
---- a/README.md
++++ b/README.md
@@@ -54,7 +54,7 @@ The media subsystem lives in **OpenVibe.Media**:
### Data storage
--- PostgreSQL `ov_live` (since 2026-10-08; `migrations/0001_analytics.sql`, `0002_live.sql`) — users, streams, channel state, AI state, request analytics. Chat storage is OpenVibe.Chat's. The SQLite files it replaced stay read-only next to the release for the rollback window.
++- PostgreSQL `ov_live` (since 2026-10-08; `migrations/0001_analytics.sql`, `0002_live.sql`) — users, streams, channel state, AI state, request analytics. Chat storage is OpenVibe.Chat's.
- `data/live-thumbs` — ephemeral live-stream thumbnails.
- `data/emotes`, `data/avatars`, `data/offline` — Live-local assets.
- `data/media/cache` — song-request (watch-party) downloads.
ddiff --git a/STATUS.json b/STATUS.json
iindex e1767ae1..cfbfe55b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -2,7 +2,7 @@
"repository": "OpenVibers/OpenVibe.Live",
"stage": "stable",
"code": true,
-- "runtime": "node 22, express, better-sqlite3, node-media-server, mediasoup; no build step",
++ "runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.126.0",
ddiff --git a/deploy/README.md b/deploy/README.md
iindex be07a064..0d475fa4 100644
---- a/deploy/README.md
++++ b/deploy/README.md
@@@ -2,11 +2,9 @@
How deploys work: [../docs/deploy.md](../docs/deploy.md).
--- `scripts/deploy.sh` — hands over to `ovhost deploy live` (OpenVibe.Host, strategy `release-layout`): `--wait-idle`, `--restart`, `--rollback` (`ovhost rollback live`), `DRY_RUN=1` (`ovhost plan live`); falls back to `deploy-legacy.sh` when ovhost is missing, too old or does not manage Live
--- `scripts/deploy-legacy.sh` — the previous deploy script, unchanged: classifies a change (static / server / dependencies / schema / units) and does the least disruptive thing
--- `scripts/migrate-to-releases.sh` — one-time move to the release layout (`releases/`, `current`, `shared/data`)
++- `scripts/deploy.sh` — runs `ovhost deploy|rollback|plan live`; requires ovhost and a managed release-layout service
- `scripts/post-deploy-check.sh` — smoke checks on the host
--- `systemd/openvibe-live.service` + `.socket` — current (git checkout) layout, socket activation
++- `systemd/openvibe-live.service` + `.socket` — socket activation template
- `systemd/release/` — units for the release layout (`OV_APP_ROOT=/opt/openvibe.live/current`)
- `nginx/openvibe.live.conf` — what production runs: limits, static cache for hashed assets, gzip, query-less logs. Apply by hand (`nginx -t`, reload)
- `fail2ban/jail.local.example` — starter fail2ban config for SSH and nginx abuse
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
ddeleted file mode 100755
iindex 030b2866..00000000
---- a/deploy/scripts/deploy-legacy.sh
++++ /dev/null
@@@ -1,478 +0,0 @@
--#!/usr/bin/env bash
--# ═══════════════════════════════════════════════════════════════
--# OpenVibe.Live — deploy
--#
--# Fetch, classify what changed, prepare, and do the least disruptive thing that makes it live.
--#
--# sudo deploy/scripts/deploy.sh deploy origin/main if it moved
--# sudo deploy/scripts/deploy.sh --wait-idle hold a restart until nobody is live (max 8h)
--# sudo deploy/scripts/deploy.sh --restart restart even if nothing changed
--# sudo deploy/scripts/deploy.sh --rollback release layout: switch back to the previous release
--# sudo deploy/scripts/deploy.sh --force legacy layout: discard local tracked changes
--# DRY_RUN=1 deploy/scripts/deploy.sh print the plan, change nothing
--#
--# What a change costs (see docs/deploy.md):
--# docs/, README etc., tests no process restart; the new files are just in place
--# public/ no restart — the server re-reads HTML and assets (content-hashed)
--# server/, vendor/, package* restart; HTTP connections queue on the systemd socket, but live
--# WebSocket, WHIP, WebRTC and RTMP sessions are dropped and reconnect
--# package-lock.json dependencies installed BEFORE anything is interrupted
--# deploy/systemd/ units installed, daemon-reload, restart
--# deploy/nginx/ never auto-installed (the live config is generated elsewhere);
--# the script says so
--#
--# After a deploy or rollback that went live: `ovhost announce live` (release notification to open tabs;
--# best effort, skipped without an ovhost that has `announce`, never changes the exit code).
--#
--# Two layouts:
--# release /opt/openvibe.live/releases/<id> (code + node_modules), current -> releases/<id>,
--# shared/data. A deploy builds a new release while the old one serves; switching is an
--# atomic symlink rename; rollback switches back to a release that still has its own
--# node_modules. Set up once with deploy/scripts/migrate-to-releases.sh.
--# legacy /opt/openvibe.live is a git checkout (what production runs until migrated).
--#
--# Exit codes: 0 success · 1 usage/precondition · 2 validation failed · 3 not ready after restart
--# (rolled back) · 4 rollback itself failed — MANUAL INTERVENTION · 5 gave up waiting for idle
--# ═══════════════════════════════════════════════════════════════
--set -euo pipefail
--
--FORCE_DEPLOY=false
--ALWAYS_RESTART=false
--WAIT_IDLE=false
--ROLLBACK=false
--while [ "$#" -gt 0 ]; do
-- case "$1" in
-- --force|-f|--ignore-local-changes) FORCE_DEPLOY=true; shift ;;
-- --restart) ALWAYS_RESTART=true; shift ;;
-- --wait-idle) WAIT_IDLE=true; shift ;;
-- --rollback) ROLLBACK=true; shift ;;
-- --) shift; break ;;
-- *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] [--force]"; exit 1 ;;
-- esac
--done
--
--BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"
--SERVICE="${SERVICE:-openvibe-live}"
--SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
--SYSTEMCTL="${SYSTEMCTL:-systemctl}"
--SS="${SS:-ss}"
--SOCKET_CHANGED=false
--# Unit files land in /etc: when systemctl is being run through sudo, so is the install.
--SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""
--SITE_URL="${SITE_URL:-https://openvibe.live}"
--API_URL="${API_URL:-http://127.0.0.1:3000}"
--GIT_REMOTE="${GIT_REMOTE:-origin}"
--GIT_BRANCH="${GIT_BRANCH:-main}"
--READY_PATH="${READY_PATH:-/api/ready}"
--UPDATES_PATH="${UPDATES_PATH:-/updates}"
--BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"
--READY_TIMEOUT="${READY_TIMEOUT:-90}"
--IDLE_MAX_SECONDS="${IDLE_MAX_SECONDS:-28800}"
--KEEP_RELEASES="${KEEP_RELEASES:-5}"
--NPM="${NPM:-npm}"
--DRY_RUN="${DRY_RUN:-0}"
--# Not /tmp: fs.protected_regular stops root from reopening a lock a plain user created there.
--LOCK_FILE="${LOCK_FILE:-$BASE_DIR/.deploy.lock}"
--
--say() { echo "[Deploy] $*"; }
--die() { echo "[Deploy] ✗ $1" >&2; exit "${2:-1}"; }
--run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }
--
--exec 9>"$LOCK_FILE"
--flock -n 9 || die "another deploy is already running (lock: $LOCK_FILE)"
--
--if [ -d "$BASE_DIR/releases" ] && [ -L "$BASE_DIR/current" ]; then LAYOUT=release; else LAYOUT=legacy; fi
--
--echo "╔══════════════════════════════════════╗"
--echo "║ OpenVibe.Live — deploy ║"
--echo "╚══════════════════════════════════════╝"
--say "base=$BASE_DIR layout=$LAYOUT service=$SERVICE branch=$GIT_BRANCH dry_run=$DRY_RUN"
--
--# ── Classification ───────────────────────────────────────────────────────────
--# Prints one word per category that the changed file list touches.
--classify() {
-- local files="$1" cats=""
-- add() { case " $cats " in *" $1 "*) ;; *) cats="$cats $1" ;; esac; }
-- while IFS= read -r f; do
-- [ -n "$f" ] || continue
-- case "$f" in
-- package.json|package-lock.json) add deps; add server ;;
-- server/db/migrations.js|server/db/schema.sql) add schema; add server ;;
-- server/*|vendor/*|.env.example) add server ;;
-- public/*) add static ;;
-- deploy/systemd/*) add systemd ;;
-- deploy/nginx/*) add nginx ;;
-- docs/*) add static ;;
-- *) add none ;;
-- esac
-- done <<< "$files"
-- echo "$cats"
--}
--has() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; }
--
--# Whether package.json changed in a way that needs an install (scripts/description edits do not).
--deps_changed() {
-- local from="$1" to="$2" dir="$3"
-- git -C "$dir" diff --quiet "$from" "$to" -- package-lock.json || return 0
-- local a b
-- a=$(git -C "$dir" show "$from:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("x")}})')
-- b=$(git -C "$dir" show "$to:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("y")}})')
-- [ "$a" != "$b" ]
--}
--
--# ── Live-stream guard ────────────────────────────────────────────────────────
--# A restart drops every RTMP/WHIP/WebRTC publisher. Socket activation keeps HTTP accepting, nothing more.
--live_count() {
-- curl -sf --max-time 5 "${API_URL}/api/streams" 2>/dev/null \
-- | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log((j.streams||[]).filter(s=>s.is_live).length)}catch{console.log(0)}})' \
-- || echo 0
--}
--wait_for_idle() {
-- [ "$WAIT_IDLE" = true ] || return 0
-- local waited=0 quiet=0
-- while [ "$waited" -lt "$IDLE_MAX_SECONDS" ]; do
-- local n; n=$(live_count)
-- if [ "$n" = "0" ]; then quiet=$((quiet+1)); else quiet=0; fi
-- [ "$quiet" -ge 2 ] && { say "no one live for two checks — proceeding"; return 0; }
-- say "$n live stream(s); holding the restart (${waited}s waited)"
-- [ "$DRY_RUN" = "1" ] && return 0
-- sleep 60; waited=$((waited+60))
-- done
-- die "still live after ${IDLE_MAX_SECONDS}s; nothing was restarted" 5
--}
--
--wait_ready() {
-- local deadline=$(( $(date +%s) + READY_TIMEOUT ))
-- while [ "$(date +%s)" -lt "$deadline" ]; do
-- if curl -sf --max-time 5 "${API_URL}${READY_PATH}" >/dev/null 2>&1; then return 0; fi
-- $SYSTEMCTL is-active --quiet "$SERVICE" || { say "service is not running"; return 1; }
-- sleep 1; printf '.'
-- done
-- printf '\n'; return 1
--}
--
--notify_chat() {
-- local summary="$1"
-- curl -sf -X POST "${API_URL}${BROADCAST_ENDPOINT}" \
-- -H "Content-Type: application/json" -H "Authorization: Bearer ${ADMIN_TOKEN:-}" \
-- -d "{\"type\":\"update\",\"summary\":$(printf '%s' "$summary" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.stringify(d.trim())))'),\"url\":\"${SITE_URL}${UPDATES_PATH}\"}" \
-- >/dev/null 2>&1 && say "chat notified" || say "chat notification skipped"
--}
--
--install_units() {
-- local unit_dir="$1" changed=false
-- install_unit() {
-- local src="$1" dest="$2"
-- [ -f "$src" ] || return 0
-- if [ ! -f "$dest" ] || ! cmp -s "$src" "$dest"; then
-- say "installing $(basename "$dest")"
-- run $SUDO_INSTALL install -m 0644 -D "$src" "$dest"
-- changed=true
-- fi
-- }
-- install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"
-- local socket_before=false
-- [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=true
-- install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"
-- # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor
-- # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then
-- # restarts the socket itself. Left alone, the service binds the port on its own and every later
-- # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).
-- [ "$socket_before" = true ] && SOCKET_CHANGED=true
-- install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"
-- if [ "$changed" = true ]; then
-- run $SYSTEMCTL daemon-reload
-- run $SYSTEMCTL enable "${SERVICE}.socket" >/dev/null 2>&1 || true
-- run $SYSTEMCTL enable "$SERVICE" >/dev/null 2>&1 || true
-- fi
--}
--
--syntax_check() {
-- local dir="$1" files="$2" checked=0
-- while IFS= read -r f; do
-- case "$f" in
-- *.js) [ -f "$dir/$f" ] || continue
-- node --check "$dir/$f" >/dev/null 2>&1 || die "syntax error in $f" 2
-- checked=$((checked+1)) ;;
-- *.json) [ -f "$dir/$f" ] || continue
-- node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$dir/$f" 2>/dev/null || die "invalid JSON in $f" 2 ;;
-- esac
-- done <<< "$files"
-- say "syntax-checked $checked changed JS file(s)"
--}
--
--backup_db() {
-- # A schema migration is about to run on restart: take a consistent online copy first.
-- local db="$1"
-- [ -f "$db" ] || { say "no database at $db to back up"; return 0; }
-- local out; out="$(dirname "$db")/backups/live-$(date +%Y%m%d-%H%M%S).db"
-- run mkdir -p "$(dirname "$out")"
-- say "schema changed — backing up $(basename "$db") to $out"
-- if [ "$DRY_RUN" != "1" ]; then
-- (cd "$2" && node -e 'const D=require("better-sqlite3");new D(process.argv[1],{readonly:true}).backup(process.argv[2]).then(()=>process.exit(0),e=>{console.error(e.message);process.exit(1)})' "$db" "$out") \
-- || die "database backup failed; nothing was restarted" 2
-- fi
--}
--
--# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose
--# descriptor was dropped still reads active while the service binds the port itself.
--socket_held() {
-- local listen port
-- listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')
-- port=${listen##*:}
-- [ -n "$port" ] || return 1
-- $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'
--}
--
--restart_and_verify() {
-- local on_fail="$1" socketed=false
-- if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then
-- socketed=true
-- $SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true
-- fi
-- local start; start=$(date +%s)
-- if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then
-- # The socket must be rebound: stop the service (it may hold the port itself), restart the
-- # socket, start the service on it. HTTP is refused for that moment, once.
-- say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"
-- $SYSTEMCTL stop "$SERVICE" 9>&- || true
-- $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"
-- $SYSTEMCTL start "$SERVICE" 9>&-
-- SOCKET_CHANGED=false
-- else
-- [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"
-- say "restarting ${SERVICE}…"
-- $SYSTEMCTL restart "$SERVICE" 9>&-
-- fi
-- printf '[Deploy] waiting for readiness'
-- if wait_ready; then
-- printf '\n'; say "ready after $(( $(date +%s) - start ))s"
-- if [ "$socketed" = true ] && ! socket_held; then
-- say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"
-- fi
-- return 0
-- fi
-- say "✗ not ready within ${READY_TIMEOUT}s"
-- journalctl -u "$SERVICE" -n 40 --no-pager 2>/dev/null | sed 's/^/ /' || true
-- $on_fail
--}
--
--# ── Release notification (WS-P task 9) ───────────────────────────────────────
--# OpenVibe.Host publishes host.release.published to OpenVibe.Events, so open tabs check /release.json now
--# instead of at their next poll. ovhost announces the release Live's own /release.json reports, once per
--# release. Best effort: skipped when ovhost is missing or has no `announce`, 20 s at most, and it never
--# fails a deploy (see OpenVibe.Host docs/release-notifications.md).
--OVHOST="${OVHOST:-ovhost}"
--announce_release() {
-- [ "$DRY_RUN" = "1" ] && return 0
-- command -v "$OVHOST" >/dev/null 2>&1 || return 0
-- local help
-- help=$("$OVHOST" --help 2>/dev/null || true)
-- case "$help" in *"announce <service>"*) ;; *) say "release notification skipped: this ovhost has no announce"; return 0 ;; esac
-- timeout 20 "$OVHOST" announce live 9>&- 2>&1 | sed 's/^/ /' || say "release notification not sent (the deploy stands)"
--}
--
--# ═══════════════════════════════════════════════════════════════
--# Release layout
--# ═══════════════════════════════════════════════════════════════
--if [ "$LAYOUT" = release ]; then
-- REPO="$BASE_DIR/repo"
-- CURRENT=$(readlink -f "$BASE_DIR/current")
-- CUR_ID=$(basename "$CURRENT")
-- CUR_SHA=$(git -C "$CURRENT" rev-parse HEAD 2>/dev/null || echo none)
-- say "current release: $CUR_ID (${CUR_SHA:0:8})"
--
-- switch_to() {
-- # Atomic: create the new link beside the old one and rename over it.
-- local target="$1"
-- run ln -sfn "$target" "$BASE_DIR/.current.tmp"
-- run mv -Tf "$BASE_DIR/.current.tmp" "$BASE_DIR/current"
-- }
--
-- if [ "$ROLLBACK" = true ]; then
-- PREV=$(ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | grep -vx "$CURRENT" | head -1 || true)
-- [ -n "$PREV" ] || die "no previous release to roll back to"
-- PREV_SHA=$(git -C "$PREV" rev-parse HEAD 2>/dev/null || echo none)
-- CATS=$(classify "$(git -C "$REPO" diff --name-only "$PREV_SHA" "$CUR_SHA" 2>/dev/null || echo server/)")
-- say "rolling back to $(basename "$PREV") (${PREV_SHA:0:8}); change classes:${CATS}"
-- [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
-- switch_to "$PREV"
-- if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then
-- install_units "$PREV/deploy/systemd/release"
-- wait_for_idle
-- restart_and_verify 'die "ROLLBACK RELEASE IS NOT READY — MANUAL INTERVENTION REQUIRED" 4'
-- else
-- say "static-only difference — switched without a restart"
-- fi
-- # Keep the release we left so it can be re-selected; just move it out of "newest".
-- touch -h "$PREV"
-- say "rolled back to $(basename "$PREV") ✅"
-- announce_release
-- exit 0
-- fi
--
-- say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
-- run git -C "$REPO" fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
-- TARGET_SHA=$(git -C "$REPO" rev-parse "${GIT_REMOTE}/${GIT_BRANCH}")
-- if [ "$TARGET_SHA" = "$CUR_SHA" ] && [ "$ALWAYS_RESTART" != true ]; then
-- say "already at ${TARGET_SHA:0:8}; nothing to deploy."
-- exit 0
-- fi
--
-- CHANGED=$(git -C "$REPO" diff --name-only "$CUR_SHA" "$TARGET_SHA" 2>/dev/null || true)
-- CATS=$(classify "$CHANGED")
-- [ "$TARGET_SHA" = "$CUR_SHA" ] && CATS=" server"
-- COMMIT_LOG=$(git -C "$REPO" --no-pager log --oneline "${CUR_SHA}..${TARGET_SHA}" 2>/dev/null || echo "Restart requested")
-- COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
-- say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
-- printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
--
-- NEW_ID="$(date +%Y%m%d-%H%M%S)-${TARGET_SHA:0:8}"
-- NEW="$BASE_DIR/releases/$NEW_ID"
-- say "preparing release $NEW_ID while $CUR_ID keeps serving…"
-- run git -C "$REPO" worktree add --detach "$NEW" "$TARGET_SHA" >/dev/null
-- [ "$DRY_RUN" = "1" ] || syntax_check "$NEW" "$CHANGED"
-- run ln -sfn ../../shared/data "$NEW/data"
--
-- if [ "$DRY_RUN" != "1" ] && deps_changed "$CUR_SHA" "$TARGET_SHA" "$REPO"; then
-- say "dependencies changed — installing into the new release (nothing interrupted yet)…"
-- (cd "$NEW" && $NPM ci --omit=dev --no-audit --no-fund) || { git -C "$REPO" worktree remove --force "$NEW" || true; die "npm ci failed; the current release is untouched" 2; }
-- else
-- # Same lockfile: hard-link the current release's node_modules — instant, no extra disk, and the
-- # new release still owns its own tree (npm never writes into an installed package in place).
-- say "dependencies unchanged — linking node_modules from $CUR_ID"
-- run cp -al "$CURRENT/node_modules" "$NEW/node_modules"
-- fi
--
-- if has "$CATS" nginx; then
-- say "note: deploy/nginx changed and is NOT installed by this script — copy it to /etc/nginx/sites-available,"
-- say " run nginx -t and reload (OpenVibe.Network's generator mirrors it)."
-- fi
--
-- [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
--
-- NEEDS_RESTART=false
-- if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
--
-- if [ "$NEEDS_RESTART" = true ]; then
-- wait_for_idle
-- has "$CATS" schema && backup_db "$BASE_DIR/shared/data/live.db" "$NEW"
-- FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
-- notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
-- switch_to "$NEW"
-- install_units "$NEW/deploy/systemd/release"
-- rollback_release() {
-- say "rolling back to $CUR_ID"
-- switch_to "$CURRENT"
-- install_units "$CURRENT/deploy/systemd/release"
-- $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
-- printf '[Deploy] waiting for rollback'
-- if wait_ready; then printf '\n'; say "rolled back to $CUR_ID and serving"; exit 3; fi
-- die "ROLLBACK FAILED: $CUR_ID is not ready either — MANUAL INTERVENTION REQUIRED" 4
-- }
-- restart_and_verify rollback_release
-- say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
-- else
-- switch_to "$NEW"
-- # The running process reads public/ and docs/ through the `current` link (OV_APP_ROOT), and
-- # re-hashes assets within ~2s. Confirm the site answers; no process was restarted.
-- sleep 3
-- curl -sf --max-time 10 "${API_URL}${READY_PATH}" >/dev/null || { switch_to "$CURRENT"; die "site not ready after switching — switched back to $CUR_ID" 3; }
-- say "switched to $NEW_ID without a restart (change classes:${CATS})"
-- fi
--
-- # Prune old releases (never the current one, never the one we just left).
-- ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | tail -n +$((KEEP_RELEASES+1)) | while read -r old; do
-- [ "$old" = "$(readlink -f "$BASE_DIR/current")" ] && continue
-- [ "$old" = "$CURRENT" ] && continue
-- say "pruning $(basename "$old")"
-- git -C "$REPO" worktree remove --force "$old" 2>/dev/null || rm -rf "$old"
-- done
-- git -C "$REPO" worktree prune 2>/dev/null || true
-- say "deployed $CUR_ID → $NEW_ID ✅"
-- announce_release
-- exit 0
--fi
--
--# ═══════════════════════════════════════════════════════════════
--# Legacy layout (git checkout in place)
--# ═══════════════════════════════════════════════════════════════
--[ "$ROLLBACK" = true ] && die "--rollback needs the release layout (deploy/scripts/migrate-to-releases.sh)"
--REPO_DIR="$BASE_DIR"
--cd "$REPO_DIR" || die "repo not found at $REPO_DIR"
--OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo none)
--say "current commit: ${OLD_HASH:0:8}"
--
--if [ "$FORCE_DEPLOY" = true ]; then
-- say "force: discarding local tracked changes (untracked files are kept)"
-- run git reset --hard HEAD
--elif ! git diff --quiet || ! git diff --cached --quiet; then
-- die "working tree has uncommitted changes — commit them, or re-run with --force"
--fi
--
--say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
--run git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
--TARGET_HASH=$(git rev-parse "${GIT_REMOTE}/${GIT_BRANCH}" 2>/dev/null || echo "$OLD_HASH")
--if [ "$OLD_HASH" = "$TARGET_HASH" ] && [ "$ALWAYS_RESTART" != true ]; then
-- say "already at ${TARGET_HASH:0:8}; nothing to deploy."
-- exit 0
--fi
--
--CHANGED=""; CATS=" server"
--if [ "$OLD_HASH" != "$TARGET_HASH" ]; then
-- CHANGED=$(git diff --name-only "$OLD_HASH" "$TARGET_HASH" || true)
-- CATS=$(classify "$CHANGED")
-- COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${TARGET_HASH}" 2>/dev/null || echo "Update deployed")
-- COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
-- say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
-- printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
--else
-- COMMIT_LOG="Restart requested"; COMMIT_COUNT=0
--fi
--
--NEEDS_RESTART=false
--if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
--
--# In place, the old process keeps running against files that are about to change underneath it, so
--# anything that needs a restart waits for idle BEFORE the checkout moves.
--[ "$NEEDS_RESTART" = true ] && wait_for_idle
--
--say "validating the target revision…"
--run git merge --ff-only "$TARGET_HASH" >/dev/null 2>&1 || { [ "$DRY_RUN" = "1" ] || die "fast-forward to ${TARGET_HASH:0:8} failed — diverged history" 2; }
--NEW_HASH=$(git rev-parse HEAD)
--[ -n "$CHANGED" ] && [ "$DRY_RUN" != "1" ] && syntax_check "$REPO_DIR" "$CHANGED"
--
--if [ "$DRY_RUN" != "1" ] && [ "$OLD_HASH" != "$TARGET_HASH" ] && deps_changed "$OLD_HASH" "$TARGET_HASH" "$REPO_DIR"; then
-- say "dependencies changed — installing (service still running)…"
-- say "warning: legacy layout — a rollback would NOT restore the previous node_modules. Migrate to releases."
-- run $NPM ci --omit=dev --no-audit --no-fund || die "npm ci failed" 2
--fi
--has "$CATS" nginx && say "note: deploy/nginx changed — not installed here; copy it, nginx -t, reload (see docs/deploy.md)."
--
--if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed. restart needed: $NEEDS_RESTART"; exit 0; fi
--
--if [ "$NEEDS_RESTART" != true ]; then
-- say "change classes:${CATS} — no restart needed; the server re-reads public/ and docs/ from disk."
-- say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} without interrupting anyone ✅"
-- announce_release
-- exit 0
--fi
--
--install_units "$REPO_DIR/deploy/systemd"
--has "$CATS" schema && backup_db "$REPO_DIR/data/live.db" "$REPO_DIR"
--FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
--notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
--rollback_legacy() {
-- say "rolling back to ${OLD_HASH:0:8}"
-- git reset --hard "$OLD_HASH" >/dev/null 2>&1 || die "ROLLBACK FAILED: could not reset to $OLD_HASH" 4
-- $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
-- printf '[Deploy] waiting for rollback'
-- if wait_ready; then printf '\n'; say "rolled back to ${OLD_HASH:0:8} and serving"; exit 3; fi
-- die "ROLLBACK FAILED: ${OLD_HASH:0:8} is not ready either — MANUAL INTERVENTION REQUIRED" 4
--}
--restart_and_verify rollback_legacy
--curl -sf --max-time 5 "${API_URL}/api/health" >/dev/null || die "health endpoint not responding after readiness" 3
--say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"
--say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
--say "done ✅"
--announce_release
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex 546f3e49..d983ec29 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -17,13 +17,10 @@
# DRY_RUN=1 deploy/scripts/deploy.sh ovhost plan live (changes nothing)
# READY_TIMEOUT=<s> --ready-timeout <s>
#
--# --force meant "discard local tracked changes" in the legacy in-place layout. ovhost never discards changes,
--# and its --force drops live streams (and passes a freeze), so the wrapper refuses it: run
++# ovhost --force drops live streams (and passes a freeze), so the wrapper refuses it: run
# `sudo ovhost deploy live --force` yourself when that is what you mean.
#
--# Fallback: deploy-legacy.sh (the previous script, unchanged) runs instead, with the same arguments, when
--# ovhost is missing or too old (no `capabilities`, deploy-api < 1), or the host inventory does not deploy
--# live with strategy release-layout. OVHOST_LEGACY=1 forces it; OVHOST=<path> picks another ovhost.
++# OVHOST=<path> picks another ovhost. An unavailable or incompatible ovhost is an error.
#
# Exit codes are ovhost's, the same as before: 0 ok · 1 usage/precondition · 2 validation failed, nothing
# restarted · 3 not ready, rolled back · 4 rollback failed (manual intervention) · 5 protected sessions ·
@@@ -33,15 +30,12 @@ set -euo pipefail
SERVICE=live
STRATEGY=release-layout
--HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
--LEGACY="${DEPLOY_LEGACY:-$HERE/deploy-legacy.sh}"
OVHOST="${OVHOST:-/usr/local/bin/ovhost}"
# ovhost runs as root; tests set OVHOST_SUDO= to call it directly.
if [ "${OVHOST_SUDO-auto}" = auto ]; then if [ "$(id -u)" -eq 0 ]; then SUDO=(); else SUDO=(sudo); fi; elif [ -n "${OVHOST_SUDO}" ]; then SUDO=("$OVHOST_SUDO"); else SUDO=(); fi
say() { echo "[Deploy] $*"; }
--ORIG=("$@")
CMD=deploy
FLAGS=()
FORCE=false
@@@ -57,15 +51,9 @@ while [ "$#" -gt 0 ]; do
done
[ -n "${READY_TIMEOUT:-}" ] && FLAGS+=(--ready-timeout "$READY_TIMEOUT")
--legacy() {
-- say "$1 — running deploy-legacy.sh (the previous deploy script) instead"
-- exec bash "$LEGACY" "${ORIG[@]}"
--}
--
# Does this ovhost deploy live with the strategy this wrapper hands over to? Sets REASON when not.
REASON=""
probe() {
-- if [ "${OVHOST_LEGACY:-0}" = 1 ]; then REASON="OVHOST_LEGACY=1"; return 1; fi
if ! command -v "$OVHOST" >/dev/null 2>&1; then REASON="ovhost not found ($OVHOST)"; return 1; fi
local caps api
if ! caps=$("${SUDO[@]}" "$OVHOST" capabilities "$SERVICE" 2>/dev/null); then REASON="this ovhost has no 'capabilities' (too old) or no inventory entry for $SERVICE"; return 1; fi
@@@ -77,11 +65,11 @@ probe() {
return 0
}
--probe || legacy "$REASON"
++if ! probe; then echo "[Deploy] ✗ $REASON" >&2; exit 1; fi
if [ "$FORCE" = true ]; then
-- echo "[Deploy] ✗ --force meant 'discard local tracked changes' (legacy layout). ovhost never discards changes, and its" >&2
-- echo "[Deploy] --force drops live streams and passes a freeze. Run: sudo $OVHOST ${CMD} ${SERVICE} --force if that is what you mean." >&2
++ echo "[Deploy] ✗ --force drops live streams and passes a freeze." >&2
++ echo "[Deploy] Run: sudo $OVHOST ${CMD} ${SERVICE} --force if that is what you mean." >&2
exit 1
fi
if [ "${DRY_RUN:-0}" = 1 ]; then
ddiff --git a/deploy/scripts/migrate-to-releases.sh b/deploy/scripts/migrate-to-releases.sh
ddeleted file mode 100755
iindex e9ff1a2b..00000000
---- a/deploy/scripts/migrate-to-releases.sh
++++ /dev/null
@@@ -1,86 +0,0 @@
--#!/usr/bin/env bash
--# ═══════════════════════════════════════════════════════════════
--# OpenVibe.Live — one-time switch from an in-place checkout to the release layout
--#
--# sudo deploy/scripts/migrate-to-releases.sh [--wait-idle] (DRY_RUN=1 to print the plan)
--#
--# Before: /opt/openvibe.live git checkout + node_modules + data/
--# After: /opt/openvibe.live/repo git clone used to create releases
--# /opt/openvibe.live/releases/<id> worktree + its own node_modules + data -> ../../shared/data
--# /opt/openvibe.live/current -> releases/<id>
--# /opt/openvibe.live/shared/data live.db, analytics.db, uploads… (moved, not copied)
--# /opt/openvibe.live/data -> shared/data (so absolute DB_PATH values keep working)
--#
--# The data move needs the service stopped (SQLite files are open), so this costs one restart — the
--# same interruption as any server deploy. Everything else is prepared first, while the site serves.
--# If the new layout does not come up ready, the move is undone and the old unit is reinstalled.
--# The old checkout files are left in place; remove them by hand once you are happy.
--# ═══════════════════════════════════════════════════════════════
--set -euo pipefail
--BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"
--SERVICE="${SERVICE:-openvibe-live}"
--SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
--SYSTEMCTL="${SYSTEMCTL:-systemctl}"
--API_URL="${API_URL:-http://127.0.0.1:3000}"
--READY_TIMEOUT="${READY_TIMEOUT:-90}"
--DRY_RUN="${DRY_RUN:-0}"
--WAIT_IDLE=false
--[ "${1:-}" = "--wait-idle" ] && WAIT_IDLE=true
--say() { echo "[Migrate] $*"; }
--die() { echo "[Migrate] ✗ $1" >&2; exit "${2:-1}"; }
--run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }
--
--[ -d "$BASE_DIR/.git" ] || die "$BASE_DIR is not a git checkout (already migrated?)"
--[ -e "$BASE_DIR/current" ] && die "$BASE_DIR/current already exists"
--git -C "$BASE_DIR" diff --quiet && git -C "$BASE_DIR" diff --cached --quiet || die "checkout has uncommitted changes"
--SHA=$(git -C "$BASE_DIR" rev-parse HEAD)
--ORIGIN=$(git -C "$BASE_DIR" remote get-url origin)
--ID="$(date +%Y%m%d-%H%M%S)-${SHA:0:8}"
--REL="$BASE_DIR/releases/$ID"
--
--say "preparing (service keeps running): repo clone, release $ID, node_modules links"
--run mkdir -p "$BASE_DIR/releases" "$BASE_DIR/shared"
--[ -d "$BASE_DIR/repo" ] || run git clone --quiet --no-checkout "$BASE_DIR" "$BASE_DIR/repo"
--run git -C "$BASE_DIR/repo" remote set-url origin "$ORIGIN"
--run git -C "$BASE_DIR/repo" fetch --quiet origin
--run git -C "$BASE_DIR/repo" worktree add --detach "$REL" "$SHA"
--run cp -al "$BASE_DIR/node_modules" "$REL/node_modules"
--run ln -sfn ../../shared/data "$REL/data"
--for f in "$REL"/deploy/systemd/release/openvibe-live.service; do [ -f "$f" ] || [ "$DRY_RUN" = "1" ] || die "release has no deploy/systemd/release unit — deploy a newer commit first"; done
--
--if [ "$WAIT_IDLE" = true ] && [ "$DRY_RUN" != "1" ]; then
-- while :; do
-- n=$(curl -sf --max-time 5 "$API_URL/api/streams" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{console.log((JSON.parse(d).streams||[]).filter(s=>s.is_live).length)}catch{console.log(0)}})' || echo 0)
-- [ "$n" = "0" ] && break
-- say "$n live stream(s); waiting a minute"; sleep 60
-- done
--fi
--
--[ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
--
--undo() {
-- say "undoing: restoring the in-place layout"
-- $SYSTEMCTL stop "$SERVICE" || true
-- [ -L "$BASE_DIR/data" ] && rm -f "$BASE_DIR/data"
-- [ -d "$BASE_DIR/shared/data" ] && mv "$BASE_DIR/shared/data" "$BASE_DIR/data"
-- rm -f "$BASE_DIR/current"
-- install -m 0644 "$BASE_DIR/deploy/systemd/openvibe-live.service" "$SYSTEMD_DIR/${SERVICE}.service"
-- $SYSTEMCTL daemon-reload
-- $SYSTEMCTL start "$SERVICE"
-- die "migration rolled back; the site runs from the checkout as before" 3
--}
--
--say "stopping $SERVICE to move data (HTTP connections queue on the socket meanwhile)…"
--$SYSTEMCTL stop "$SERVICE"
--mv "$BASE_DIR/data" "$BASE_DIR/shared/data" || undo
--ln -s shared/data "$BASE_DIR/data" || undo
--ln -sfn "releases/$ID" "$BASE_DIR/current" || undo
--install -m 0644 "$REL/deploy/systemd/release/openvibe-live.service" "$SYSTEMD_DIR/${SERVICE}.service" || undo
--$SYSTEMCTL daemon-reload
--$SYSTEMCTL start "$SERVICE" || undo
--deadline=$(( $(date +%s) + READY_TIMEOUT ))
--until curl -sf --max-time 5 "$API_URL/api/ready" >/dev/null 2>&1; do
-- [ "$(date +%s)" -lt "$deadline" ] || undo
-- sleep 1
--done
--say "running from releases/$ID ✅ (old checkout files in $BASE_DIR can be removed by hand)"
ddiff --git a/deploy/systemd/release/openvibe-live.service b/deploy/systemd/release/openvibe-live.service
iindex 5ef87487..c3ada92d 100644
---- a/deploy/systemd/release/openvibe-live.service
++++ b/deploy/systemd/release/openvibe-live.service
@@@ -1,5 +1,3 @@
--# Release layout (deploy/scripts/migrate-to-releases.sh). The unit in deploy/systemd/ is the legacy,
--# in-place checkout variant and stays valid until a host is migrated.
[Unit]
Description=OpenVibe.Live — Open Live Streaming, Community Run
# Media (openvibe-media) and Network (openvibe-network) should be up first, but
ddiff --git a/docs/adr-live-postgres-slice-1.md b/docs/adr-live-postgres-slice-1.md
ddeleted file mode 100644
iindex 0a9bb0e2..00000000
---- a/docs/adr-live-postgres-slice-1.md
++++ /dev/null
@@@ -1,21 +0,0 @@
--# Live PostgreSQL migration: slice 1
--
--Status: implementation slice, 2026-09-30. The default remains SQLite.
--
--## Decision
--
--Move page analytics first, behind `ANALYTICS_DRIVER=postgres`. It is Live's second SQLite database and already has a PostgreSQL implementation in the pinned `openvibe-shared` release. Its request-facing `record()` stays synchronous while the PostgreSQL tracker buffers writes; only the three analytics read routes and the retention job need to await database work. This makes the slice deployable independently of the 2,000-plus synchronous calls to Live's primary database.
--
--`ANALYTICS_DRIVER` defaults to `sqlite`. With `postgres`, `DATABASE_URL` connects the runtime tracker to PostgreSQL. Run `DATABASE_DIRECT_URL=... npm run migrate:analytics-pg` with the schema owner before enabling it. The migration is versioned and checked by the SDK. Startup checks that `analytics_events` exists; the runtime role never creates tables. `LIVE_DRILL` uses its isolated SQLite analytics file regardless of the driver setting and starts no analytics timers or outbound calls. Existing SQLite analytics data is not imported by this slice; preserve the file and import its history before a production analytics switch if historical dashboards must be continuous.
--
--## Target architecture
--
--The primary Live database will use `openvibe-sdk/db` with `DATABASE_URL`, versioned PostgreSQL migrations on `DATABASE_DIRECT_URL`, and async methods retaining the current exported names. Generate its schema from a booted SQLite database, omit the frozen tables, and move all boot-time DDL into migrations. PostgreSQL analytics then shares that database and migration ledger. Live's outbox and inbox use SDK PostgreSQL implementations; page analytics uses `openvibe-shared/analytics/pg`. The cutover imports and verifies data through the Host switch procedure. The SQLite file remains the rollback copy for the agreed window.
--
--## Next slices
--
--1. Land the remaining Chat table retirement and settle money ownership before generating the primary schema, so deleted tables are never imported.
--2. Add the primary database's async driver and generated schema in a separately tested slice; convert one bounded caller group at a time, including its transactions and route handlers. Keep SQLite as the default until every caller is async.
--3. Convert the outbox/inbox, analytics history, drill guard, readiness, remaining callers, and tests; rehearse the import with row counts and checksums before the Host cutover.
--
--The source survey is `openvibe/agents/jobs/brief-t4-live-pg.out.md`. The PostgreSQL analytics test runs against `OV_TEST_PG_DIRECT_URL`; it skips when the disposable service is unavailable.
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex a73b0cd0..3575fa2b 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -79,8 +79,7 @@ path are deleted.
writer since; Live's readers and writers are gone (#28–#32 and the release that carries this, #33)
and it no longer creates them. #33 is in production and its N-1 fixtures no longer run SQL over the
tables ([test/n-1.test.js](../test/n-1.test.js)), so the drop is boot migration `007_drop_chat_tables`
-- ([server/db/migrations.js](../server/db/migrations.js)) (ADR-028) — see
-- [docs/cutover-chat-tables-003.md](cutover-chat-tables-003.md). The deployment notice is Chat's too: Live
++ (ADR-028). The deployment notice is Chat's too: Live
queues `live.release.deployed` and Chat folds and stores the rolling card. `test/chat-tables-dropped.test.js`
guards that nothing under `server/` names them.
ddiff --git a/docs/cutover-chat-tables-003.md b/docs/cutover-chat-tables-003.md
ddeleted file mode 100644
iindex 53888ba9..00000000
---- a/docs/cutover-chat-tables-003.md
++++ /dev/null
@@@ -1,71 +0,0 @@
--# Cutover: drop Live's copies of OpenVibe.Chat's chat tables (T3 final step, boot migration)
--
--Live keeps no references to the twelve chat tables OpenVibe.Chat owns — `chat_messages`,
--`dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`,
--`channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`
--and `moderation_actions` — and does not create them. Boot migration **`007_drop_chat_tables`**
--([server/db/migrations.js](../server/db/migrations.js)) drops them, with their indexes, on the next
--boot after deploy.
--
--Chat imported these tables with ids kept at the 2026-09-23 cutover and has been their only writer
--since; Live's readers and writers are gone (PRs #28–#32 and the release that carries this migration,
--#33). #33 is the production release, and its N-1 fixtures no longer run SQL over the tables
--([test/n-1.test.js](../test/n-1.test.js)), so — unlike the earlier operator step `op_003` — the drop
--is an ordinary boot migration (ADR-028, `test/fixtures/destructive-migrations.json`). The tables are
--Live's own SQLite tables in `live.db`, not the shared PostgreSQL.
--
--The previous operator migration `op_003_drop_chat_tables` and its script
--[scripts/chat-tables-drop.js](../scripts/chat-tables-drop.js) are removed: they existed only while
--the release before still read the tables. An operator who already ran `op_003` needs nothing — the
--tables are gone, so `007` is adopted; a database that never ran it drops them on the next boot. An
--`op_003_drop_chat_tables` row left in `schema_migrations` is harmless (the ledger ignores ids no
--current migration uses).
--
--## Order
--
--1. Deploy this release. `initDb()` runs `007_drop_chat_tables` on the first boot and records
-- `applied` (or `adopted` when none of the twelve exists, e.g. a fresh database or one where
-- `op_003` already ran).
--2. `ov access run openvibe-ovh health live` answers `ready` and a restart prints no `no such table`
-- for any of the twelve. No operator action is needed; there is no script to run.
--
--## Backup
--
--- The host's snapshots (`ov access run openvibe-ovh db-backup`) taken before the deploy are the way
-- back to a database that still holds the rows. Taking one before deploying this release is
-- recommended, as for any contract step.
--- The rows themselves live in OpenVibe.Chat, which imported them with ids kept — the backup is the
-- operator's safety net, not their home.
--
--## How to check success
--
--- **Drop applied.** The read-only check on the host prints nothing for the tables and `applied` for
-- the ledger row:
--
--```sh
--sqlite3 -readonly <Live's data dir>/live.db \
-- "SELECT name FROM sqlite_master WHERE name IN ('chat_messages','dm_conversations','dm_participants','dm_messages','dm_blocks','tts_voice_overrides','channel_sounds','relay_users','hidden_relay_users','pending_ip_messages','stream_first_chats','moderation_actions')" # → nothing
--sqlite3 -readonly <Live's data dir>/live.db \
-- "SELECT id, mode FROM schema_migrations WHERE id = '007_drop_chat_tables'" # → 007_drop_chat_tables|applied
--```
--
--- **Nothing else broke.** `ov access run openvibe-ovh health live` answers `ready`; home stats and the
-- admin chat console still answer (from Chat), and a chat line still reaches the watch page.
--- **Idempotent.** A later boot keeps the ledger row and drops nothing further.
--
--## Rollback / restore
--
--Dropping the tables is destructive and a code rollback does not bring them back — the previous
--release runs fine without them (it reads them only on paths production does not take). Restore only
--if the backup turns out to be needed: roll back the release (`ov access run openvibe-ovh rollback
--live`), stop Live, copy the host `db-backup` `live.db` over Live's database as the service user with
--the original owner and mode and no stale `-wal`/`-shm` beside it, and start Live. Restoring also
--discards every other Live write made since the backup, so prefer leaving the tables dropped.
--
--## Rehearsal
--
--[test/chat-tables-dropped.test.js](../test/chat-tables-dropped.test.js) checks the static guard
--(nothing under `server/` names any of the twelve, the drop migration's own `DROP` statements
--excepted), that a fresh schema creates none of them, and that the boot migration drops legacy copies
--on a database that has them (`applied`) and is adopted on a fresh one. `test/n-1.test.js` proves the
--previous release still prepares every statement it runs on a database this release migrated.
ddiff --git a/docs/cutover-emotes-006.md b/docs/cutover-emotes-006.md
ddeleted file mode 100644
iindex 446cb666..00000000
---- a/docs/cutover-emotes-006.md
++++ /dev/null
@@@ -1,117 +0,0 @@
--# Cutover: migration 006_drop_emotes (T3 N+3)
--
--PR #1 (merge `70b67309`, "T3 N+3: drop Live's emotes copy (Chat owns it)") adds the destructive migration
--`006_drop_emotes` ([server/db/migrations.js](../server/db/migrations.js)): on the first boot of the new release
--Live runs `DROP TABLE IF EXISTS emotes` on its SQLite database and records `006_drop_emotes` as `applied` in
--`schema_migrations`. A database without the table (a fresh install) records it as `adopted` and drops nothing.
--The same PR removes `emotes` from [server/db/schema.sql](../server/db/schema.sql) and the two inline emotes
--migrations from [server/db/database.js](../server/db/database.js), so nothing recreates the table.
--
--## Preconditions
--
--- **The production release `fb3957f` no longer reads Live's `emotes`.** It is the N-1 release for this deploy;
-- `test/n-1.test.js` replays its recorded statements against the new schema and
-- `test/chat-staged-tables.test.js` fails on any `server/` or `scripts/` read or write of the table. The
-- old emote routes (`server/emotes/routes.js`) were deleted in N+1 (`8654ef6`).
--- **OpenVibe.Chat owns and serves emotes.** nginx sends `openvibe.live/api/emotes` to Chat; the dashboard,
-- channel page and upload UI call Chat's `/api/chat/channels/:id/…` and `/api/emotes`; Live reads only through
-- Chat's internal read API ([docs/chat-system.md](chat-system.md), "The six chat tables are Chat's").
--- **No copy into Chat is needed, and none must be made.** Chat's `emotes` already holds Live's rows: before the
-- T3 flip, Live's relay snapshotted `live.db`, imported the snapshot into Chat and relayed every later change
-- (`chat-tables-sync.js`, deleted in `8654ef6`); the flip of all six tables to Chat on 2026-09-29 14:46 UTC
-- happened after dual-read parity. Since then every emote upload, edit and delete has been written by Chat
-- only, so Live's table is a stale snapshot from the flip. Copying it back into Chat would undo the changes
-- made since. Both databases were also backed up at the flip (`/var/lib/openvibe-backups/t3-20260929T144556Z`).
--- `ov access run openvibe-ovh health live` answers `ready` before you start.
--
--## Order
--
--1. `ov access run openvibe-ovh db-backup`: back up the databases now. Note the Live backup's stamp
-- (`<backupDir>/live/<YYYYMMDD-HHMMSS>/live.db`, recorded in `<stateDir>/backups/live.jsonl`) and check that it is
-- `ok: true`. This is the only way back to a database that still has the table.
--2. `ov access run openvibe-ovh health live`: `ready`.
--3. `ov access run openvibe-ovh deploy live -- --wait-idle`: ships the range since the running release (it
-- includes `70b67309`). The first boot runs 006.
--4. `ov access run openvibe-ovh health live`: `ready`.
--
--## How to check success
--
--- **Ledger.** `GET https://openvibe.live/api/admin/diagnostics` (admin session) → `migrations` has
-- `{ "id": "006_drop_emotes", "mode": "applied" }`; no entry is `failed`. The boot log
-- (`ov access run openvibe-ovh logs live`) has `[DB] migration 006_drop_emotes applied in …ms` once, and a later
-- restart does not print it again.
--- **The table is gone.** On the host, read-only:
-- `sqlite3 -readonly <Live's data dir>/live.db "SELECT name FROM sqlite_master WHERE name = 'emotes'"` prints
-- nothing, and `"SELECT id, mode FROM schema_migrations WHERE id = '006_drop_emotes'"` prints
-- `006_drop_emotes|applied`.
--- **Emotes still render from Chat.** `curl -s https://openvibe.live/api/emotes` (served by Chat) lists the
-- emotes; on a watch page with channel emotes, chat shows a sent emote code as its image, and the emote panel
-- lists the channel's and the global emotes. Nothing in Live's logs mentions `no such table: emotes`.
--
--## Rollback
--
--`ov access run openvibe-ovh rollback live` alone does **not** bring the table back: it switches to the previous
--release's code against the same database, and `fb3957f` does not need the table, so it runs, but the
--dropped rows are gone. To get Live's `emotes` copy back (there is no reason to expect it to be needed: Chat
--serves emotes and holds newer rows than this copy), the owner restores the step 1 backup with the previous
--release:
--
--1. `ov access run openvibe-ovh rollback live` (back to the previous release).
--2. On the host: stop Live, copy the step 1 backup's `live.db` over Live's database (as the service user, owner
-- and mode as before, with no stale `-wal`/`-shm` beside it), start Live.
--3. `ov access run openvibe-ovh health live`: `ready`.
--
--Restoring the backup also discards every other Live write made between step 1 and the restore, so prefer
--leaving the table dropped: a missing `emotes` table affects nothing that runs.
--
--## Rehearsal
--
--Rehearsal `live-emotes-006`, 2026-10-01, Node 22.22.1 (production's; the shared `node_modules` has
--`better-sqlite3` built for it), on exports of the two commits in `/tmp/ov-emotes-006/{old,new}`
--(`git archive 70b6730^1` = `51f772b`, `git archive 70b6730`). `rehearse.js` opens `DATA_DIR`'s `live.db` through
--the given release's `db.initDb()` — the call `server/index.js` boots with, schema plus `migrations.run()` —
--then seeds (`seed`: 2 users, 3 emotes, then writes every table's row count to `DATA_DIR/counts.json`) or reports
--(`check`: the 006 ledger row, whether `emotes` exists, every other table's count against `counts.json`).
--
--```
--$ export PATH=~/.local/share/fnm/node-versions/v22.22.1/installation/bin:$PATH; R=/tmp/ov-emotes-006
--$ DATA_DIR=$R/data node rehearse.js $R/old seed # pre-PR code, 70b6730^1
--[DB] Rebuilt emotes table: code uniqueness is now per-channel
--seeded: emotes=3 users=2 tables=82
--ledger: 001_vibes_decimal_to_bits,002_pastes_ai_columns,004_hot_path_indexes,005_drop_chat_staged_tables
--$ DATA_DIR=$R/data node rehearse.js $R/new check # new code, 70b6730: first boot
--[DB] migration 006_drop_emotes applied in 1ms
--006 ledger rows: [{"id":"006_drop_emotes","mode":"applied"}]
--emotes table exists: false
--other tables compared: 80, count changes: none, missing: none
--schema_migrations rows: 4 -> 5
--$ DATA_DIR=$R/data node rehearse.js $R/new check # second boot: no-op (no "applied" line)
--006 ledger rows: [{"id":"006_drop_emotes","mode":"applied"}]
--emotes table exists: false
--other tables compared: 80, count changes: none, missing: none
--schema_migrations rows: 4 -> 5
--$ DATA_DIR=$R/fresh node rehearse.js $R/new check # fresh database
--006 ledger rows: [{"id":"006_drop_emotes","mode":"adopted"}]
--emotes table exists: false
--```
--
--`npm test` at `70b6730` (Node 22.22.1):
--
--```
--$ cd $R/new && set -o pipefail && npm test # the git-less export
--✓ account-data.test.js / ✓ chat-staged-tables.test.js / ✓ destructive-migrations.test.js
--✓ frozen-tables.test.js / ✓ n-1.test.js
--✗ drill-mode.test.js Error: Command failed: git rev-parse --short=12 HEAD (not a git repository)
--✗ release-events.test.js git rev-parse: '' does not match /^[0-9a-f]{40}$/
--152/157 test files passed, 3 skipped (… rollback-newer-writes.test.js: skipped (no git history …))
--$ # the three git-dependent files, in the git worktree at 70b6730 (NODE_PATH = the same node_modules)
--$ node test/drill-mode.test.js → drill-mode: all checks passed (21 passed, 0 failed)
--$ node test/release-events.test.js → release events: … — all checks passed
--$ node test/rollback-newer-writes.test.js → rollback with newer writes: the release of ae589aca works on this release's database, and back again
--```
--
--The two failures in the export are only the missing `.git`; in the git checkout all three pass (drill-mode's
--symlink check also needs the checkout's `data/` to exist, as it does in production). The other two skips are
--environmental (no PostgreSQL URL, no local model).
--
--REHEARSAL: green
ddiff --git a/docs/cutover-pr-openvibe-live-21.md b/docs/cutover-pr-openvibe-live-21.md
ddeleted file mode 100644
iindex 0a7cd901..00000000
---- a/docs/cutover-pr-openvibe-live-21.md
++++ /dev/null
@@@ -1,94 +0,0 @@
--# Cutover: drop Live's `chat_bridge_outbox` (T3 J2, operator migration)
--
--This PR ("Deliver chat only through Chat's ingress and drop the bridge outbox") deletes Live's old ordered-calls
--chat bridge ([server/chat/chat-remote.js](../server/chat/chat-remote.js), `POST /internal/live/calls`) and leaves
--[server/chat/chat-delivery.js](../server/chat/chat-delivery.js) as the only path into OpenVibe.Chat, through Chat's
--typed ingress. It adds the operator migration **`op_002_drop_chat_bridge_outbox`**
--([server/db/migrations.js](../server/db/migrations.js)) and the script that runs it,
--[scripts/chat-bridge-outbox-drop.js](../scripts/chat-bridge-outbox-drop.js).
--
--Unlike the boot migrations (`001…006`), `op_002` is **not** applied on boot: the release before this one still
--writes `chat_bridge_outbox`, so the table may only be dropped once that release is out of rollback range
--(ADR-028). The operator runs the script by hand after this release has been stable for the rollback window.
--
--`chat_bridge_outbox` is Live's own SQLite table, so this cutover is on Live's `live.db`, not on the shared
--PostgreSQL. The script has three modes:
--
--- **(default) dry run** — prints the queued rows by op and boot; changes nothing.
--- **`--deliver`** — hands every queued row to Chat's bridge receiver (`POST /internal/live/calls`, Live's
-- service token for audience `openvibe.chat`, capability `chat.live_bridge.write`), in the order and boot the
-- old bridge wrote them, each with idempotency key `live:<row id>` so Chat applies a write at most once.
-- Acknowledged rows are deleted; a row Chat refuses is left for the operator; a transport error stops the run
-- to be rerun later.
--- **`--apply`** — takes an online backup of `live.db`, then runs `op_002`, which
-- **refuses while any unacknowledged chat write (`op = 'db'`) is still queued**, so a write the old release queued is never lost.
--
--## Order
--
--1. Confirm this release is the running one and has been through the rollback window:
-- `ov access run openvibe-ovh health live` answers `ready` and `ov access run openvibe-ovh releases live`
-- shows the new release as current (the old writer no longer in rollback range).
--2. Stop nothing; `--deliver` and `--apply` run against the live database. From the release directory as the
-- service user: `node scripts/chat-bridge-outbox-drop.js` (dry run) — note the counts.
--3. If the dry run shows queued chat writes: `node scripts/chat-bridge-outbox-drop.js --deliver` and rerun the
-- dry run until it reports **0** chat writes (`op = 'db'`). Rows Chat refused stay and are reported with their
-- ids; take those to the operator (they are not deleted).
--4. `node scripts/chat-bridge-outbox-drop.js --apply` — writes
-- `<database dir>/backups/live-pre-chat-bridge-drop-<time>.db` (mode 0600) and runs the drop. The ledger
-- outcome is printed (`applied`, or `adopted` on a database that never had the table, `already` on a repeat).
--
--## Backup
--
--- The script takes its own `live.db` backup before it drops anything and prints the path (step 4). Restoring
-- that file is the way back to a database that still holds the table's rows.
--- A wider snapshot is also taken by the host: `ov access run openvibe-ovh db-backup` records Live's
-- `<backupDir>/live/<YYYYMMDD-HHMMSS>/live.db` with `ok: true` in `<stateDir>/backups/live.jsonl`. Either copy
-- is enough; keep the script's own file as it is the exact pre-drop state.
--
--## How to check success (verification)
--
--- **Delivery first.** After `--deliver`, the dry run prints `chat writes (op = 'db') Chat has not
-- acknowledged: 0`. The delivered messages appear in Chat (reload a watch page: they load from Chat).
--- **Drop applied.** `--apply` prints `op_002_drop_chat_bridge_outbox: applied`, and the read-only check on the
-- host prints nothing for the table and `applied` for the ledger row:
--
--```sh
--sqlite3 -readonly <Live's data dir>/live.db \
-- "SELECT name FROM sqlite_master WHERE name = 'chat_bridge_outbox'" # → nothing
--sqlite3 -readonly <Live's data dir>/live.db \
-- "SELECT id, mode FROM schema_migrations WHERE id = 'op_002_drop_chat_bridge_outbox'" # → op_002_…|applied
--```
--
--- **Nothing else broke.** `ov access run openvibe-ovh health live` answers `ready`; restarting Live does not
-- print the migration again, and its logs contain no `no such table: chat_bridge_outbox`. Live's chat still
-- delivers: send a chat message and see it in the Chat-served history, and the deploy-notice path still fires.
--- **Idempotent.** Rerunning `--apply` prints `already` and drops nothing further.
--
--## Rollback / restore
--
--Dropping the table is destructive and a code rollback does **not** bring it back — the previous release runs
--fine without the table (it is a chat send queue, not state). Only restore if a queued chat write turns out to
--have been needed:
--
--1. `ov access run openvibe-ovh rollback live` (back to the previous release).
--2. Stop Live and copy the script's backup (or the host `db-backup`) `live.db` over Live's database, as the
-- service user, with the original owner and mode and no stale `-wal`/`-shm` beside it; start Live.
--3. `ov access run openvibe-ovh health live`: `ready`, then `--deliver` to flush the restored queue.
--
--Restoring also discards every other Live write made since the backup, so prefer leaving the table dropped.
--Stop Live only for the copy; `--deliver`/`--apply` themselves run online.
--
--## Rehearsal
--
--The migration and the delivery/drop contract are exercised by [test/chat-bridge-removed.test.js](../test/chat-bridge-removed.test.js)
--against an in-memory SQLite database: it builds `chat_bridge_outbox`, checks that `op_002` refuses while a chat
--write is queued, that `deliver()` sends queued rows in order per boot with keys `live:<id>` and deletes only
--what Chat acknowledged, and that the drop then applies (and is `already` on a second run). The PR's PostgreSQL
--migrations (`migrations/`) are unchanged, so the harness base/migrate steps apply nothing new; the command below
--is the runbook's part.
--
--```rehearse
--seed: none
--migrations: migrations
--node test/chat-bridge-removed.test.js
--```
ddiff --git a/docs/cutover-pr-openvibe-live-22.md b/docs/cutover-pr-openvibe-live-22.md
ddeleted file mode 100644
iindex 7648a3c0..00000000
---- a/docs/cutover-pr-openvibe-live-22.md
++++ /dev/null
@@@ -1,83 +0,0 @@
--# Cutover: channel ↔ Bot robot binding (PR #22, `channels.bot_robot_id`)
--
--PR #22 (`c3e93a4`, "Bind a channel to a Bot robot and expose botembed behind LIVE_BOTEMBED") is a data change:
--it adds one nullable column to Live's own SQLite database.
--
--- [server/db/schema.sql](../server/db/schema.sql) declares `channels.bot_robot_id TEXT` (after
-- `active_control_config_id`).
--- [server/db/database.js](../server/db/database.js) adds the same column idempotently on boot
-- (`ALTER TABLE channels ADD COLUMN bot_robot_id TEXT` when `PRAGMA table_info(channels)` lacks it) and accepts
-- `bot_robot_id` in `updateChannel`'s allowlist.
--- [server/bot/embed.js](../server/bot/embed.js) and [server/bot/routes.js](../server/bot/routes.js) read and write it
-- behind `LIVE_BOT_EMBED` (off by default): with the flag off the `PUT /api/streams/channel/:username/bot` route answers
-- 404 and the channel JSON has no `bot_embed` key; the stored id is kept.
--- [server/web/serializers.js](../server/web/serializers.js) never leaks `bot_robot_id` on public channel rows; it is only
-- surfaced as `bot_embed` on `GET /api/streams/channel/:username` (see [README.md](../README.md), "Bot panel embed").
--
--The change is **additive and non-destructive**: a new nullable column, no rewrite, backfill or delete, no read of any
--existing row, and no PostgreSQL migration (Live's `channels` table is SQLite). Every existing query, the previous release
--and a rollback all ignore the extra column, so the deploy carries no data-movement step.
--
--## Preconditions
--
--- `ov access run openvibe-ovh health live` answers `ready` before you start.
--- The range from the running release to this head carries no other data change; auto-finish refuses such a range,
-- deploy those commits through their own runbook first.
--- The flag order below: **Bot deployed first**. `GET /panel/<robot id>/embed` must exist in OpenVibe.Bot and its
-- `BOT_EMBED_ORIGINS` must include this site's origins, or the embed frame stays empty.
--
--## Order
--
--1. **Back up.** `ov access run openvibe-ovh db-backup`: back up the databases now and check the Live backup's stamp
-- (`<backupDir>/live/<YYYYMMDD-HHMMSS>/live.db`, `ok: true` in `<stateDir>/backups/live.jsonl`). This is the way back to
-- a database without the column; the deploy's own pre-deploy backup also runs because `server/db/schema.sql` changed.
--2. **Deploy with the flag off** (the default). `ov access run openvibe-ovh deploy live -- --wait-idle`: ships the range
-- since the running release and restarts, gated on `/api/ready`. Boot adds the column; no data moves.
--3. **Verify** (below), then, later and separately on the owner's go, **turn the flag on**: Bot deployed → set
-- `LIVE_BOT_EMBED=1` in `/etc/openvibe/live.env` → `ov access run openvibe-ovh restart live` while no one is live →
-- bind a channel. Step 3 is not part of this deploy.
--
--## How to check success (verification)
--
--- **Boot log.** `ov access run openvibe-ovh logs live 200` prints `[DB] Added bot_robot_id column to channels` once on
-- the first boot of the new release and never again; no `no such column: bot_robot_id` and no migration `failed`.
--- **Column present.** On the host, read-only:
-- `sqlite3 -readonly <Live's data dir>/live.db "SELECT name FROM pragma_table_info('channels') WHERE name = 'bot_robot_id'"`
-- prints `bot_robot_id`, and every other column is unchanged.
--- **App still serves.** `ov access run openvibe-ovh health live` is `ready`; `GET https://openvibe.live/api/streams/channel/<a live channel>`
-- returns 200 and, with the flag off, carries no `bot_embed` key (and never a `bot_robot_id`). With the flag on, the owner
-- binds with `PUT /api/streams/channel/:username/bot {"robot_id":"rob_…"}` and the same GET shows
-- `bot_embed: { enabled: true, robot_id, url }`.
--- `test/bot-embed-binding.test.js` covers the route and the channel JSON (the pipeline's verify runs it).
--
--## Rollback / restore
--
--`ov access run openvibe-ovh rollback live` alone is enough: the previous release's code does not read, write or serialize
--`bot_robot_id`, so it runs against this database unchanged, and the dropped-flag state is simply the default. Then
--`ov access run openvibe-ovh health live` must be `ready`. No restore is needed — the column is additive and unused.
--
--If a restore is ever wanted anyway (to a database literally without the column), the owner restores the step 1 backup with
--the previous release: `ov rollback live`; on the host stop Live, copy the backup's `live.db` over Live's database (as the
--service user, owner and mode as before, no stale `-wal`/`-shm` beside it), start Live, `health live`. Restoring also
--discards every other Live write made since step 1, so prefer the plain rollback: the extra column affects nothing that
--runs.
--
--## Rehearsal
--
--The harness runs `ov rehearse OpenVibe.Live 22` on its scratch PostgreSQL (main's migrations, the repository's fixtures,
--this PR's migrations, then the block below). This PR adds no migration under `migrations/` (the column is SQLite and
--added inline), so the rehearsal's migrate step applies nothing on the second run; the commands confirm the change is
--present and parses.
--
--```rehearse
--# The column is SQLite-only (schema.sql + the idempotent ADD COLUMN in database.js): the scratch PostgreSQL
--# holds only the analytics slice, so these check the change is declared, added idempotently and exposed.
--node --check server/bot/embed.js
--node --check server/bot/routes.js
--grep -q 'bot_robot_id TEXT' server/db/schema.sql
--grep -q 'ADD COLUMN bot_robot_id TEXT' server/db/database.js
--grep -q "LIVE_BOT_EMBED === '1'" server/bot/embed.js
--grep -q "bot_robot_id" server/web/serializers.js
--```
--
--REHEARSAL: green
ddiff --git a/docs/deploy.md b/docs/deploy.md
iindex ba20b2ce..cd2a27ed 100644
---- a/docs/deploy.md
++++ b/docs/deploy.md
@@@ -13,15 +13,11 @@ sudo deploy/scripts/deploy.sh --rollback # ovhost rollback live: the release t
DRY_RUN=1 deploy/scripts/deploy.sh # ovhost plan live: print the plan only
```
--The wrapper checks `ovhost capabilities live` first (deploy API 1, strategy `release-layout`, managed).
--When ovhost is missing or too old, or the host inventory does not deploy Live with that strategy, it says
--why and runs `deploy/scripts/deploy-legacy.sh`, the previous script, unchanged and with the same
--arguments. `OVHOST_LEGACY=1` forces the fallback; `OVHOST=<path>` picks another ovhost. `--force`
--meant "discard local tracked changes" in the legacy layout; ovhost's `--force` drops live streams, so the
--wrapper refuses it (run `sudo ovhost deploy live --force` yourself if that is what you mean). Everything
--below is what both do; ovhost also records every attempt in its release log (`ovhost releases live`) and
--refuses a frozen service (`ovhost freeze`, exit 6). What ovhost checks is in OpenVibe.Host
--`docs/deploy-strategies.md` and `test/strategy-release-layout.test.js`.
++The wrapper checks `ovhost capabilities live` (deploy API 1, strategy `release-layout`, managed).
++If ovhost is unavailable or incompatible, the wrapper exits 1. `OVHOST=<path>` picks another ovhost.
++`--force` has different semantics in ovhost, so the wrapper refuses it; run
++`sudo ovhost deploy live --force` explicitly if needed. ovhost records each attempt in its release log
++(`ovhost releases live`) and refuses a frozen service (`ovhost freeze`, exit 6).
## What each kind of change costs
@@@ -42,23 +38,15 @@ Clients reconnect with jittered backoff and show "OpenVibe is updating" / "Recon
## Layouts
--**Release layout (production since 2026-09-24 05:51 UTC;** set up once with
--`deploy/scripts/migrate-to-releases.sh`, one restart). Deploy with
--`cd /opt/openvibe.live/current && sudo deploy/scripts/deploy.sh`: the files left in `/opt/openvibe.live`
--from the old checkout are stale, including their copy of this script. The release directories are
--root-owned, so the manifest takes the release id from the directory name (`<time>-<sha8>`), and the
--host inventory runs git for Live as root (`owner: root`, `runAs: ubuntu` for drills).
--
--**Legacy (before 2026-09-24):** `/opt/openvibe.live` was a git checkout. A rollback reset the checkout
--but could not restore previous `node_modules`.
++**Release layout (production):** deploy from `/opt/openvibe.live/current`.
++The release directories are root-owned, so the manifest takes the release id from the directory name
++(`<time>-<sha8>`), and the host inventory runs git for Live as root (`owner: root`, `runAs: ubuntu` for drills).
```
/opt/openvibe.live/repo git clone used to create releases
/opt/openvibe.live/releases/<time>-<sha> worktree + its own node_modules + data -> ../../shared/data
/opt/openvibe.live/current -> releases/<id> (atomic rename)
--/opt/openvibe.live/shared/data uploads and runtime files (never copied or reset); live.db and analytics.db stay
-- read-only there since the switch to PostgreSQL (2026-10-08), the rollback copy
--/opt/openvibe.live/data -> shared/data (old absolute data paths keep working)
++/opt/openvibe.live/shared/data uploads and runtime files (never copied or reset)
```
- A deploy builds the new release while the old one serves; unchanged lockfiles hard-link
@@@ -80,19 +68,14 @@ but could not restore previous `node_modules`.
- The last 5 releases are kept.
- Content-hashed assets from the previous release are still served under their old hashes, so a page
rendered before the switch never loads JavaScript from after it.
--- **Release notification.** After a deploy or `--rollback` that went live, ovhost announces it (the
-- legacy script runs `ovhost announce live`; OpenVibe.Host, WS-P task 9). ovhost publishes `host.release.published` for the
++- **Release notification.** After a deploy or `--rollback` that went live, ovhost announces it through OpenVibe.Host. It publishes `host.release.published` for the
release `/release.json` now reports, once per release, to OpenVibe.Events. Open tabs (openvibe-shared
1.17.0 release-watch) then check `/release.json` within about 20 s instead of at their next poll. It is
-- best effort: skipped when `ovhost` is missing or has no `announce` (set `OVHOST` for another path), 20 s at
-- most, and it never changes the exit code. A static-only switch keeps the running release, so there is
++ best effort and does not change the exit code. A static-only switch keeps the running release, so there is
nothing new to announce. The credentials and the setup are in OpenVibe.Host `docs/release-notifications.md`.
--`test/deploy-sim.test.js` runs `deploy-legacy.sh` against a simulated host (real git, fake systemctl)
--and checks each of these behaviours; `test/deploy-wrapper.test.js` checks the wrapper's flag mapping and
--its fallback. ovhost's own tests (OpenVibe.Host `test/strategy-release-layout.test.js`) cover the same
--behaviours for `ovhost deploy live`, plus the socket rule: pid 1 must hold :3000, and the socket unit is
--restarted only when its unit file changed or systemd does not hold the listener.
++`test/deploy-wrapper.test.js` checks the wrapper's flag mapping and errors. ovhost's own tests
++(OpenVibe.Host `test/strategy-release-layout.test.js`) cover release behavior and the socket rule.
## Assets and caching
@@@ -139,7 +122,7 @@ PGlite database under `DATA_DIR`). In that mode
## Checks
```bash
--npm test # unit, security, migrations, deploy simulation, size budgets
++npm test # unit, security, migrations, size budgets
BASE=http://127.0.0.1:3000 npm run test:browser # needs a running server and Chrome
deploy/scripts/post-deploy-check.sh # on the host after a deploy
```
@@@ -148,17 +131,16 @@ deploy/scripts/post-deploy-check.sh # on the host after a deploy
For 24 hours after a deploy (ADR-016) open tabs run the previous release's client against the new
server, and a previous-release process may still be working on the database the new one migrated.
--`test/n-1.test.js` (in `npm test`, so in CI) checks both from fixtures recorded from the release in
--production:
++`test/n-1.test.js` (in `npm test`, so in CI) checks compatibility using fixtures recorded from
++the release in production:
- `test/fixtures/n-1/client.json`: every call the previous release's client code makes, and every
script, stylesheet and link of the shell and a channel page it served, with the status, JSON-ness
and the response fields the client reads. This checkout boots in the drill sandbox (writes let
-- through) on a database created with the previous schema, answers each call compatibly, and keeps
-- every read field. The calls nginx sends to OpenVibe.Chat are Chat's N-1 test.
--- `test/fixtures/n-1/worker.json`: the previous schema, migration ledger and every SQL statement that
-- release ran or has as a literal. After this release's migrations each must still prepare, and no
-- old INSERT may miss a new NOT NULL column.
++ through) on a migrated PGlite database, answers each call compatibly, and keeps every read
++ field. The calls nginx sends to OpenVibe.Chat are Chat's N-1 test.
++- `test/fixtures/n-1/worker.json`: the previous release's migration filenames and hashes. The
++ test ensures each migration remains present and unchanged.
After each deploy, record the release now in production as the next release's N-1 and commit it:
ddiff --git a/docs/performance-audit.md b/docs/performance-audit.md
iindex 9f43d0d6..bd406bc7 100644
---- a/docs/performance-audit.md
++++ b/docs/performance-audit.md
@@@ -105,7 +105,7 @@ What did **not** improve: layout on the home page is dominated by the page's own
## Deploy
--| Change type | Before | After (verified in `test/deploy-sim.test.js`) |
++| Change type | Before | After (release layout) |
|---|---|---|
| docs / public only | restart (legacy script restarted on every commit) | no restart; release layout switches `current`, same PID |
| server code | restart | restart, readiness-gated, automatic rollback |
ddiff --git a/docs/restream-branding.md b/docs/restream-branding.md
iindex feb15c5c..62ee8e94 100644
---- a/docs/restream-branding.md
++++ b/docs/restream-branding.md
@@@ -153,7 +153,7 @@ The entire platform — streaming server, chat, media pipeline, game, everything
⭐ https://github.com/OpenVibers
--Built with Node.js, WebSockets, SQLite, FFmpeg, and mediasoup.
++Built with Node.js, WebSockets, PostgreSQL, FFmpeg, and mediasoup.
```
---
ddiff --git a/docs/runbooks/live-analytics-pg-slice-1.md b/docs/runbooks/live-analytics-pg-slice-1.md
ddeleted file mode 100644
iindex f267709b..00000000
---- a/docs/runbooks/live-analytics-pg-slice-1.md
++++ /dev/null
@@@ -1,100 +0,0 @@
--# Cutover runbook: page analytics on PostgreSQL, slice 1
--
--PR #2 (merge `7deab36`, [ADR](../adr-live-postgres-slice-1.md)) adds a PostgreSQL store for page
--analytics behind `ANALYTICS_DRIVER`. It ships in two separate steps:
--
--- **A. Deploy with the gate off.** The code goes live, analytics stay in SQLite, no data moves. This is
-- the deploy the harness's `cutover` manifest covers.
--- **B. Turn the gate on.** Later, on the owner's go, as its own change. Not part of A.
--
--Rehearsal name: `live-analytics-pg-slice-1` (the harness records it in
--`ds/deploy/rehearsals/live-analytics-pg-slice-1.json` once the checks below are green).
--
--## Why A moves no data
--
--- `server/analytics/store.js:8`: the driver defaults to `sqlite`. The only PostgreSQL branch is
-- `store.js:15-34`, and `openvibe-sdk/db` and `openvibe-shared/analytics/pg` are required inside it
-- (`:17-18`), so with the gate off they are not even loaded and no connection is opened.
--- `store.js:36-46`: the SQLite store is what `server/index.js` built before PR #2: the same
-- `paths.analyticsDbPath()`, WAL, the same `AnalyticsTracker` from the same `openvibe-shared` v2.3.1,
-- `retention: false`, and the same prune. `ready()` is a no-op (`store.js:44`, awaited at
-- `server/index.js:1122`).
--- Nothing migrates at boot. The only `.migrate(` call is `scripts/analytics-pg-migrate.js:13`, which runs
-- by hand with `DATABASE_DIRECT_URL`. ovhost's Live config takes its pre-deploy backup only on
-- `server/db/migrations.js` or `server/db/schema.sql` changes and runs nothing from `migrations/`.
--- Restore drills always use their own SQLite file, whatever the driver says (`store.js:14-15`).
--- It fails safe. If `live.env` did say `ANALYTICS_DRIVER=postgres`, boot would throw before
-- `/api/ready` answers 200: at `store.js:16` without `DATABASE_URL`, or at `store.js:26` because the
-- tables were never migrated. ovhost then switches `current` back (exit 3).
--
--## A. Deploy with the gate off
--
--What the deploy does: `package-lock.json` changed (adds `pg`), so `npm ci` runs into the new release
--before anything is interrupted. `server/` changed, so the service restarts, gated on `/api/ready`.
--`--wait-idle` holds the restart until nobody is live.
--
--Preconditions:
--
--1. No freeze, and a green verify of the head being deployed.
--2. The range from the running release to that head carries no other data change. Auto-finish refuses a
-- range like that; deploy those commits through their own runbook first.
--3. `/etc/openvibe/live.env` does not set `ANALYTICS_DRIVER=postgres`. Count it, never print it:
-- `sudo grep -c '^ANALYTICS_DRIVER=postgres' /etc/openvibe/live.env` must print `0`. If nobody can check,
-- the fail-safe above still holds.
--
--Steps:
--
--```bash
--ov access run openvibe-ovh health live # must be ready before you start
--ov access run openvibe-ovh deploy live -- --wait-idle
--ov access run openvibe-ovh health live # ready again
--ov access run openvibe-ovh logs live 200
--```
--
--Check the logs. None of these may appear: `ANALYTICS_DRIVER must be`,
--`DATABASE_URL is required`, `analytics_events`, `[Analytics] Shutdown:`, a PostgreSQL connection error from `live-analytics`.
--`/api/admin/analytics?hours=1` (admin) shows pageviews after the switch, so SQLite is still recording.
--
--## Rollback
--
--```bash
--ov access run openvibe-ovh rollback live
--ov access run openvibe-ovh health live
--```
--
--This selects the previous release with its own `node_modules`. Rolling back is safe for analytics because
--this release writes `analytics.db` with the same tracker and the same `openvibe-shared` version, and wrote
--nothing to PostgreSQL. `live.db` is not touched by PR #2. `test/rollback-newer-writes.test.js` still
--covers booting last week's release on this release's `live.db`.
--
--## B. Turn the gate on (later, separate)
--
--Not part of A, and it needs the owner's go. Rehearse it on a restored copy first.
--
--1. **Roles.** A schema owner for `DATABASE_DIRECT_URL` and a runtime role for `DATABASE_URL` with DML only:
-- the runtime never creates tables (`store.js:25`). The OpenVibe.Host PostgreSQL procedure provisions
-- them.
--2. **Migrate.** From the current release on the host, with `DATABASE_DIRECT_URL` set for this one
-- command, run `npm run migrate:analytics-pg`. It must print `migrations applied: 1; held: 0`, and a
-- second run must print `applied: 0`. The migration is expand-only (`CREATE … IF NOT EXISTS`).
--3. **History.** This slice does not import `analytics.db`. Keep that file. If dashboards must stay
-- continuous, import the history before switching (a separate, rehearsed step).
--4. **Switch.** In `live.env`, set `ANALYTICS_DRIVER=postgres` and `DATABASE_URL`, then
-- `ov access run openvibe-ovh restart live` while nobody is live. Then run `health live` and check the
-- logs for the errors listed under A. Admin analytics should show new pageviews, and the count of
-- `analytics_events` rows for `service = 'live'` should grow.
--5. **Off again.** Remove `ANALYTICS_DRIVER` (or set it to `sqlite`) and restart. Analytics go back to
-- `analytics.db`. Rows recorded in PostgreSQL during the window stay there and are not merged back. The
-- tables are additive, so there is nothing to undo.
--
--## Rehearsal `live-analytics-pg-slice-1`
--
--Green when all of these pass on the head being deployed:
--
--- `npm test` (the harness verify): the full suite, with the SQLite default checked by
-- `test/analytics-store.test.js`.
--- `test/analytics-pg.test.js` against the SDK's disposable PostgreSQL (`OV_TEST_PG_DIRECT_URL` set, so it
-- does not skip). It must print
-- `analytics PostgreSQL: migration, record, privacy, reads, and prune passed`. This covers the migration
-- (applied once, then idempotent), recording, privacy (no IP, user id or city stored), reads and prune.
--- `test/rollback-newer-writes.test.js`.
ddiff --git a/docs/vibe-coding.md b/docs/vibe-coding.md
iindex 152f9e1f..c4ab2e7b 100644
---- a/docs/vibe-coding.md
++++ b/docs/vibe-coding.md
@@@ -32,7 +32,7 @@ Persistent slot settings live in `managed_streams.broadcast_settings.vibe_coding
}
```
--Durable runtime data is stored in two SQLite tables:
++Durable runtime data is stored in two PostgreSQL tables:
- `vibe_coding_sessions` — one row per `managed_stream_id + session_key`
- `vibe_coding_events` — canonical sanitized events keyed by `managed_stream_id + event_id`
ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 535fdca7..460faff9 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -3,8 +3,8 @@
> Written before the media split. Storage, cutting, thumbnails and files now live in OpenVibe.Media;
> Live proxies to it (`server/media-proxy/`) and starts/stops recordings (`server/streaming/recorder.js`).
> Pastes live in OpenVibe.Community. Live's old local `vods`/`clips`/`pastes` tables are frozen: nothing
--> writes or reads them ([below](#live-reads-nothing-from-the-frozen-tables)), and they are dropped by
--> [this procedure](#dropping-the-frozen-tables). Implementation details below may be out of date.
++> writes or reads them ([below](#live-reads-nothing-from-the-frozen-tables)); the PostgreSQL
++> schema has no copies of them. Implementation details below may be out of date.
## Recording System
@@@ -149,141 +149,3 @@ and its rows moved to Media at the split.
- **Community: a public paste count.** The hero reads Community's staff-only `/api/pastes/admin/stats` with
Live's `community.paste.moderate` grant. Media's `/stats` and `/stats/series/pastes` still count Media's
frozen paste copies; the hero's paste chart does too.
--
--## Dropping the frozen tables
--
--Register item C-73 step 3. **Start no earlier than 30 days after the deploy that contains C-73 step 2**
--(the commit that made `test/frozen-tables.test.js` refuse reads), and only if every deploy since then passed
--that test. Nothing in the procedure needs Live to stop. It drops five tables from Live's database:
--`vods`, `clips`, `pastes`, `paste_likes`, `paste_comments`. Their indexes go with them. Live's `comments` table
--is not part of this (OpenVibe.Community's comment import reads it).
--
--Paths on the production host (check `DB_PATH` in `/etc/openvibe/live.env` first):
--
--```bash
--LIVE_DB=/opt/openvibe.live/data/live.db # -> /opt/openvibe.live/shared/data/live.db
--MEDIA_DB=/opt/openvibe.media/data/media.db
--COMMUNITY_DB=/var/lib/openvibe-community/community.db
--STAMP=$(date -u +%Y%m%d)
--BACKUP_DIR=/opt/backups
--```
--
--### 1. Ship the code that stops creating the tables
--
--One Live commit, deployed before anything is dropped. Without it, the next boot recreates the five tables empty.
--
--- `server/db/schema.sql`: remove `CREATE TABLE vods`, `CREATE TABLE clips` and the indexes
-- `idx_vods_user_id`, `idx_clips_stream_id`, `idx_clips_user_id`.
--- `server/db/database.js` `initDb()`: remove every block that creates or alters these tables. That is the `vods`
-- column adds (thumbnail_url through is_recording, storage_provider/storage_key, ai_overview/ai_transcript/
-- ai_analyzed_at, visibility), the `clips` column adds (storage_provider/storage_key, the AI columns,
-- clip_notified/clip_notify_at, visibility, auto_generated), the two visibility backfills, and the `pastes`,
-- `paste_likes` and `paste_comments` blocks (tables, indexes, the copies/likes/is_nsfw columns). The
-- `idx_vods_*`/`idx_clips_*` listing and transcript indexes already left the boot code in step 2; the ones
-- production still has go with their tables.
--- `server/db/migrations.js`: leave `002_pastes_ai_columns` in place. Its ledger row exists in production;
-- with the table gone, `up` returns `DEFER` and changes nothing.
--- `test/frozen-tables.test.js`: empty `ALLOWED_WRITES` (the backfills are gone), and add a check that
-- `initDb()` on a new database creates none of the five tables.
--
--Deploying this changes no data: the existing tables stay where they are, and nothing reads, writes, creates or
--alters them.
--
--### 2. Back up
--
--```bash
--sudo mkdir -p "$BACKUP_DIR"
--# The whole database, online (safe while Live runs):
--sudo sqlite3 "$LIVE_DB" ".timeout 10000" ".backup '$BACKUP_DIR/live-pre-c73-drop-$STAMP.db'"
--# The five tables alone, as SQL that recreates them:
--sudo sqlite3 -readonly "$LIVE_DB" ".dump vods clips pastes paste_likes paste_comments" \
-- | gzip -9 | sudo tee "$BACKUP_DIR/live-frozen-tables-$STAMP.sql.gz" > /dev/null
--sudo sqlite3 -readonly "$BACKUP_DIR/live-pre-c73-drop-$STAMP.db" "PRAGMA integrity_check" # ok
--sudo sha256sum "$BACKUP_DIR"/live-pre-c73-drop-$STAMP.db "$BACKUP_DIR"/live-frozen-tables-$STAMP.sql.gz \
-- | sudo tee "$BACKUP_DIR/live-c73-$STAMP.sha256"
--```
--
--Keep both files for at least a year, and copy them off the host with the usual backups. They are the only
--copy of any row that step 3 finds missing upstream.
--
--### 3. Reconcile (read-only)
--
--```bash
--sudo sqlite3 -readonly "$LIVE_DB" <<SQL
--.headers on
--SELECT 'vods' AS t, COUNT(*) AS n FROM vods UNION ALL SELECT 'clips', COUNT(*) FROM clips
--UNION ALL SELECT 'pastes', COUNT(*) FROM pastes UNION ALL SELECT 'paste_likes', COUNT(*) FROM paste_likes
--UNION ALL SELECT 'paste_comments', COUNT(*) FROM paste_comments;
--ATTACH '$MEDIA_DB' AS m;
--ATTACH '$COMMUNITY_DB' AS c;
--SELECT 'vods missing in Media' AS t, COUNT(*) AS n FROM main.vods v
-- WHERE NOT EXISTS (SELECT 1 FROM m.vods x WHERE x.id = v.id AND x.app_id = 'live');
--SELECT 'clips missing in Media' AS t, COUNT(*) AS n FROM main.clips k
-- WHERE NOT EXISTS (SELECT 1 FROM m.clips x WHERE x.id = k.id AND x.app_id = 'live');
--SELECT 'pastes missing in Community' AS t, COUNT(*) AS n FROM main.pastes p
-- WHERE NOT EXISTS (SELECT 1 FROM c.pastes x WHERE x.slug = p.slug);
--SELECT 'paste_likes missing in Media' AS t, COUNT(*) AS n FROM main.paste_likes l
-- WHERE NOT EXISTS (SELECT 1 FROM m.paste_likes x WHERE x.paste_id = l.paste_id AND x.user_id = l.user_id);
--SELECT 'paste_comments missing in Media' AS t, COUNT(*) AS n FROM main.paste_comments pc
-- WHERE NOT EXISTS (SELECT 1 FROM m.paste_comments x WHERE x.id = pc.id);
---- Anything else in Live's schema that still points at the five tables (foreign keys, views, triggers):
--SELECT m2.name AS referencing_table, f."table" AS frozen FROM sqlite_master m2, pragma_foreign_key_list(m2.name) f
-- WHERE m2.type = 'table' AND f."table" IN ('vods','clips','pastes','paste_likes','paste_comments')
-- AND m2.name NOT IN ('vods','clips','pastes','paste_likes','paste_comments');
--SELECT type, name FROM sqlite_master WHERE type IN ('view','trigger')
-- AND (sql LIKE '%vods%' OR sql LIKE '%clips%' OR sql LIKE '%pastes%' OR sql LIKE '%paste_likes%' OR sql LIKE '%paste_comments%');
--SQL
--```
--
--The media split dropped these tables from `live.db` and `schema.sql` recreated them empty, so the first query
--most likely returns 0 for all five. Whatever it returns, **every "missing" count must be 0, and the last two
--queries must return no rows.** Otherwise stop: export the missing rows to Media or Community first (and log
--them in the register), or fix what still references the tables, then run step 3 again.
--
--### 4. Drop
--
--Children first, foreign keys off, in one transaction. Live keeps running: it has no statement on these tables.
--
--```bash
--sudo sqlite3 "$LIVE_DB" <<'SQL'
--.timeout 10000
--PRAGMA foreign_keys = OFF;
--BEGIN IMMEDIATE;
--DROP TABLE IF EXISTS paste_comments;
--DROP TABLE IF EXISTS paste_likes;
--DROP TABLE IF EXISTS clips;
--DROP TABLE IF EXISTS pastes;
--DROP TABLE IF EXISTS vods;
--COMMIT;
--PRAGMA foreign_key_check;
--SQL
--```
--
--If the tables held many rows, reclaim the space later with `VACUUM` at a quiet hour. It locks the database
--while it runs.
--
--### 5. Verify
--
--```bash
--sudo sqlite3 -readonly "$LIVE_DB" "SELECT name FROM sqlite_master WHERE name IN ('vods','clips','pastes','paste_likes','paste_comments')" # nothing
--sudo systemctl restart openvibe-live # socket activation: no 502s; boot ~6 s
--curl -fsS http://127.0.0.1:3000/api/ready
--sudo sqlite3 -readonly "$LIVE_DB" "SELECT name FROM sqlite_master WHERE name IN ('vods','clips','pastes','paste_likes','paste_comments')" # still nothing
--sudo journalctl -u openvibe-live --since '-10 min' | grep -iE 'no such table|SQLITE_ERROR' || echo clean
--for p in /api/streams/recent /api/streams/recently-online /api/streams/recent-vods /api/home/hero; do
-- curl -fsS -o /dev/null -w "%{http_code} $p\n" "http://127.0.0.1:3000$p"; done
--```
--
--Then open a channel page (Videos, Clips, Pastes and AI tabs), the home page, and the dashboard's session history.
--
--### Rollback
--
--Nothing in Live reads the tables, so a rollback is only ever needed to get rows back. Recreate the five tables
--from the dump (it holds their `CREATE TABLE`, the rows and the indexes):
--
--```bash
--gunzip -c "$BACKUP_DIR/live-frozen-tables-$STAMP.sql.gz" | sudo sqlite3 "$LIVE_DB"
--```
--
--`live-pre-c73-drop-$STAMP.db` is the second copy. Afterwards, record the date, the counts from step 3 and the
--backup paths under C-73 in OpenVibe.Host's `docs/compatibility-register.md`.
ddiff --git a/package-lock.json b/package-lock.json
iindex 620d8591..decb2447 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -36,8 +36,7 @@
"ws": "^8.18.0"
},
"devDependencies": {
-- "@electric-sql/pglite": "^0.5.8",
-- "better-sqlite3": "^11.3.0"
++ "@electric-sql/pglite": "^0.5.8"
},
"engines": {
"node": ">=18.0.0"
@@@ -965,55 +964,6 @@
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
-- "node_modules/better-sqlite3": {
-- "version": "11.10.0",
-- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
-- "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
-- "devOptional": true,
-- "hasInstallScript": true,
-- "license": "MIT",
-- "dependencies": {
-- "bindings": "^1.5.0",
-- "prebuild-install": "^7.1.1"
-- }
-- },
-- "node_modules/bindings": {
-- "version": "1.5.0",
-- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
-- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "file-uri-to-path": "1.0.0"
-- }
-- },
-- "node_modules/bl": {
-- "version": "4.1.0",
-- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
-- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "buffer": "^5.5.0",
-- "inherits": "^2.0.4",
-- "readable-stream": "^3.4.0"
-- }
-- },
-- "node_modules/bl/node_modules/readable-stream": {
-- "version": "3.6.2",
-- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
-- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "inherits": "^2.0.3",
-- "string_decoder": "^1.1.1",
-- "util-deprecate": "^1.0.1"
-- },
-- "engines": {
-- "node": ">= 6"
-- }
-- },
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
@@@ -1038,31 +988,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
-- "node_modules/buffer": {
-- "version": "5.7.1",
-- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
-- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
-- "devOptional": true,
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT",
-- "dependencies": {
-- "base64-js": "^1.3.1",
-- "ieee754": "^1.1.13"
-- }
-- },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@@ -1310,32 +1235,6 @@
"ms": "2.0.0"
}
},
-- "node_modules/decompress-response": {
-- "version": "6.0.0",
-- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
-- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "mimic-response": "^3.1.0"
-- },
-- "engines": {
-- "node": ">=10"
-- },
-- "funding": {
-- "url": "https://github.com/sponsors/sindresorhus"
-- }
-- },
-- "node_modules/deep-extend": {
-- "version": "0.6.0",
-- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
-- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
-- "devOptional": true,
-- "license": "MIT",
-- "engines": {
-- "node": ">=4.0.0"
-- }
-- },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@@ -1550,16 +1449,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/end-of-stream": {
-- "version": "1.4.5",
-- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
-- "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "once": "^1.4.0"
-- }
-- },
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
@@@ -1639,16 +1528,6 @@
"node": ">=0.8.x"
}
},
-- "node_modules/expand-template": {
-- "version": "2.0.3",
-- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
-- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
-- "devOptional": true,
-- "license": "(MIT OR WTFPL)",
-- "engines": {
-- "node": ">=6"
-- }
-- },
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@@ -1767,13 +1646,6 @@
"node": "^12.20 || >= 14.13"
}
},
-- "node_modules/file-uri-to-path": {
-- "version": "1.0.0",
-- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
-- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
-- "devOptional": true,
-- "license": "MIT"
-- },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@@ -1828,13 +1700,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/fs-constants": {
-- "version": "1.0.0",
-- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
-- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
-- "devOptional": true,
-- "license": "MIT"
-- },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@@ -1906,13 +1771,6 @@
"node": ">= 0.4"
}
},
-- "node_modules/github-from-package": {
-- "version": "0.0.0",
-- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
-- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
-- "devOptional": true,
-- "license": "MIT"
-- },
"node_modules/global-agent": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz",
@@@ -2141,13 +1999,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
-- "node_modules/ini": {
-- "version": "1.3.8",
-- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
-- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
-- "devOptional": true,
-- "license": "ISC"
-- },
"node_modules/ip-address": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
@@@ -2544,19 +2395,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/mimic-response": {
-- "version": "3.1.0",
-- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
-- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
-- "devOptional": true,
-- "license": "MIT",
-- "engines": {
-- "node": ">=10"
-- },
-- "funding": {
-- "url": "https://github.com/sponsors/sindresorhus"
-- }
-- },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@@ -2599,13 +2437,6 @@
"mkdirp": "bin/cmd.js"
}
},
-- "node_modules/mkdirp-classic": {
-- "version": "0.5.3",
-- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
-- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
-- "devOptional": true,
-- "license": "MIT"
-- },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@@ -2662,13 +2493,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
-- "node_modules/napi-build-utils": {
-- "version": "2.0.0",
-- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
-- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
-- "devOptional": true,
-- "license": "MIT"
-- },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@@ -2678,19 +2502,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/node-abi": {
-- "version": "3.94.0",
-- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
-- "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "semver": "^7.3.5"
-- },
-- "engines": {
-- "node": ">=10"
-- }
-- },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@@ -2809,16 +2620,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/once": {
-- "version": "1.4.0",
-- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
-- "devOptional": true,
-- "license": "ISC",
-- "dependencies": {
-- "wrappy": "1"
-- }
-- },
"node_modules/onnxruntime-common": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz",
@@@ -3088,34 +2889,6 @@
"node": ">=0.10.0"
}
},
-- "node_modules/prebuild-install": {
-- "version": "7.1.3",
-- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
-- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
-- "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "detect-libc": "^2.0.0",
-- "expand-template": "^2.0.3",
-- "github-from-package": "0.0.0",
-- "minimist": "^1.2.3",
-- "mkdirp-classic": "^0.5.3",
-- "napi-build-utils": "^2.0.0",
-- "node-abi": "^3.3.0",
-- "pump": "^3.0.0",
-- "rc": "^1.2.7",
-- "simple-get": "^4.0.0",
-- "tar-fs": "^2.0.0",
-- "tunnel-agent": "^0.6.0"
-- },
-- "bin": {
-- "prebuild-install": "bin.js"
-- },
-- "engines": {
-- "node": ">=10"
-- }
-- },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@@ -3139,17 +2912,6 @@
"url": "https://opencollective.com/express"
}
},
-- "node_modules/pump": {
-- "version": "3.0.4",
-- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
-- "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "end-of-stream": "^1.1.0",
-- "once": "^1.3.1"
-- }
-- },
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
@@@ -3208,22 +2970,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/rc": {
-- "version": "1.2.8",
-- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
-- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
-- "devOptional": true,
-- "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
-- "dependencies": {
-- "deep-extend": "^0.6.0",
-- "ini": "~1.3.0",
-- "minimist": "^1.2.0",
-- "strip-json-comments": "~2.0.1"
-- },
-- "bin": {
-- "rc": "cli.js"
-- }
-- },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@@ -3511,53 +3257,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
-- "node_modules/simple-concat": {
-- "version": "1.0.1",
-- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
-- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
-- "devOptional": true,
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT"
-- },
-- "node_modules/simple-get": {
-- "version": "4.0.1",
-- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
-- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
-- "devOptional": true,
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT",
-- "dependencies": {
-- "decompress-response": "^6.0.0",
-- "once": "^1.3.1",
-- "simple-concat": "^1.0.0"
-- }
-- },
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
@@@ -3617,16 +3316,6 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
-- "node_modules/strip-json-comments": {
-- "version": "2.0.1",
-- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
-- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
-- "devOptional": true,
-- "license": "MIT",
-- "engines": {
-- "node": ">=0.10.0"
-- }
-- },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@@ -3655,58 +3344,6 @@
"node": ">=18"
}
},
-- "node_modules/tar-fs": {
-- "version": "2.1.5",
-- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
-- "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "chownr": "^1.1.1",
-- "mkdirp-classic": "^0.5.2",
-- "pump": "^3.0.0",
-- "tar-stream": "^2.1.4"
-- }
-- },
-- "node_modules/tar-fs/node_modules/chownr": {
-- "version": "1.1.4",
-- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
-- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
-- "devOptional": true,
-- "license": "ISC"
-- },
-- "node_modules/tar-stream": {
-- "version": "2.2.0",
-- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
-- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "bl": "^4.0.3",
-- "end-of-stream": "^1.4.1",
-- "fs-constants": "^1.0.0",
-- "inherits": "^2.0.3",
-- "readable-stream": "^3.1.1"
-- },
-- "engines": {
-- "node": ">=6"
-- }
-- },
-- "node_modules/tar-stream/node_modules/readable-stream": {
-- "version": "3.6.2",
-- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
-- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
-- "devOptional": true,
-- "license": "MIT",
-- "dependencies": {
-- "inherits": "^2.0.3",
-- "string_decoder": "^1.1.1",
-- "util-deprecate": "^1.0.1"
-- },
-- "engines": {
-- "node": ">= 6"
-- }
-- },
"node_modules/thunky": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
@@@ -3755,19 +3392,6 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
-- "node_modules/tunnel-agent": {
-- "version": "0.6.0",
-- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
-- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
-- "devOptional": true,
-- "license": "Apache-2.0",
-- "dependencies": {
-- "safe-buffer": "^5.0.1"
-- },
-- "engines": {
-- "node": "*"
-- }
-- },
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
@@@ -3938,13 +3562,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
-- "node_modules/wrappy": {
-- "version": "1.0.2",
-- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-- "devOptional": true,
-- "license": "ISC"
-- },
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
ddiff --git a/package.json b/package.json
iindex ad20f7d6..cac086a4 100644
---- a/package.json
++++ b/package.json
@@@ -57,7 +57,6 @@
"node": ">=18.0.0"
},
"devDependencies": {
-- "@electric-sql/pglite": "^0.5.8",
-- "better-sqlite3": "^11.3.0"
++ "@electric-sql/pglite": "^0.5.8"
}
}
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 24a8c6a2..c7aa9425 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -58,7 +58,7 @@
<thead><tr><th>Layer</th><th>Tech</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>Backend</td><td>Node.js + Express</td><td>All routes return JSON</td></tr>
--<tr><td>Database</td><td>SQLite (better-sqlite3)</td><td>WAL mode, foreign keys</td></tr>
++<tr><td>Database</td><td>PostgreSQL</td><td>via openvibe-sdk/db</td></tr>
<tr><td>WebRTC SFU</td><td>Mediasoup</td><td>Video/audio routing</td></tr>
<tr><td>RTMP</td><td>Node-Media-Server</td><td>OBS classic ingest</td></tr>
<tr><td>Chat/Control</td><td>WebSocket (ws)</td><td>Real-time messaging</td></tr>
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
ddeleted file mode 100644
iindex db81eb10..00000000
---- a/scripts/migrate-to-postgres.js
++++ /dev/null
@@@ -1,116 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * The one-time move of Live's two SQLite databases into its PostgreSQL schema (plan T4, ADR-035; the procedure is
-- * openvibe-sdk docs/migrating-to-postgresql.md, section 6). Run at the cutover, with the service stopped.
-- *
-- * node scripts/migrate-to-postgres.js --sqlite <live.db> [--analytics <analytics.db>] [--pglite] [--json]
-- *
-- * --sqlite Live's main database (production: /opt/openvibe.live/shared/data/live.db). Opened read-only.
-- * --analytics the page-analytics database (default: analytics.db under DATA_DIR, else production's shared/data): its analytics_*
-- * tables go into the same PostgreSQL database (migrations/0001_analytics.sql). Opened read-only.
-- * --pglite a dry run in an in-memory PostgreSQL: migrations applied, every table imported and verified.
-- * --json the report as JSON.
-- *
-- * Without --pglite it applies migrations/ as the owner on DATABASE_DIRECT_URL (the serving DATABASE_URL goes through
-- * PgBouncer in transaction mode, which cannot hold the import's transaction) and copies into emptied tables, so a
-- * rehearsal is repeatable. Exit 0 only if every table verified (row count and a checksum of every row).
-- *
-- * What is imported is what migrations/0002_live.sql creates: a SQLite table the schema left out (the frozen vods/clips/
-- * pastes tables, OpenVibe.Chat's tables, retired features, the SQLite ledger) is not read. event_outbox rows come along
-- * (production's had none unsent when the schema was generated; sent rows are pruned after a week as before), and so do
-- * the inbox's idempotency_receipts, so a redelivered event is still recognised.
-- */
--const path = require('path');
--const { createDb, importSqlite } = require('openvibe-sdk/db');
--// cleaningOptions is sqlite-cli.js's export, beside openvibe-sdk/db's entry (the package exports map does not list it).
--const { cleaningOptions } = require(path.join(path.dirname(require.resolve('openvibe-sdk/db')), 'sqlite-cli.js'));
--
--const MIGRATIONS = path.join(__dirname, '..', 'migrations');
--
--// importSqlite's per-table options. A SQLite column the schema dropped would be listed here (dropColumns); none is.
--const TABLES = {};
--// SQLite tables migrations/0002_live.sql leaves out (its header says why); importSqlite reads none of them.
--const SKIP_SOURCE = [
-- // frozen since C-73: OpenVibe.Media and OpenVibe.Community own the data
-- 'vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'comments',
-- // owned by OpenVibe.Chat since T3 (Live keeps no copy)
-- 'channel_moderators', 'channel_moderation_settings', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events', 'chat_dual_read_stats',
-- // SQLite-only machinery: the SQLite migration ledger and the T3 chat staging/bridge queues
-- 'schema_migrations', 'chat_staged_outbox', 'chat_bridge_outbox',
-- // retired features no server code names (the legacy game, canvas, arena v1/v2, chatter profiles): their rows stay
-- // in the archived SQLite file
-- 'game_world_state', 'game_inventory', 'game_bank', 'game_structures', 'game_farm_plots', 'game_recipes', 'game_effects',
-- 'game_battle_stats', 'game_dungeon_runs', 'game_leaderboard', 'game_fish_collection', 'game_daily_quest_progress',
-- 'game_daily_quest_claims', 'game_achievements', 'tag_guardian_defeats', 'canvas_settings', 'canvas_tiles', 'canvas_actions',
-- 'canvas_snapshots', 'canvas_region_locks', 'canvas_bans', 'canvas_user_overrides', 'arena_battles', 'arena_votes',
-- 'arena_talk_topics', 'arena_talk', 'arena_talk_hype', 'arena_talk_sessions', 'arena_talk_session_topics',
-- 'arena_talk_session_hype', 'arena_topics', 'arena_topic_progress', 'arena_topic_members', 'arena_topic_hype',
-- 'arena_topic_sides', 'arena_viewer_clout', 'arena_beef_sides', 'arena_topic_moments', 'chatter_profiles', 'chatter_xp_log',
-- 'chatter_subjects', 'arena_topic_threads', 'arena_achievements', 'arena_events', 'arena_tier_paid', 'promo_claims',
-- 'moderation_events_backfill',
--];
--// The analytics database has only openvibe-shared's analytics tables (and its old per-IP rate counters, which
--// PostgreSQL has no table for: the rate check keeps them in memory, and IPs are never carried over).
--const ANALYTICS_SKIP_SOURCE = ['analytics_rate_tracking'];
--
--/** Migrate `owner` (a createDb() handle, the owner role) and copy both SQLite files into it → { ok, reports }. */
--async function importInto(owner, { sqlite, analytics = null, log = { log() {}, warn: console.warn, error: console.error } }) {
-- await owner.migrate({ dir: MIGRATIONS, log });
-- const reports = [];
-- const main = await cleaningOptions(owner, TABLES);
-- const r1 = await importSqlite({ sqlite, db: owner, truncate: true, tables: main.tables, skipSource: SKIP_SOURCE, log });
-- reports.push({ source: sqlite, ...r1, cleaned: main.cleaned() });
-- if (analytics) {
-- const an = await cleaningOptions(owner, {});
-- const r2 = await importSqlite({ sqlite: analytics, db: owner, truncate: true, tables: an.tables, skipSource: ANALYTICS_SKIP_SOURCE, log });
-- reports.push({ source: analytics, ...r2, cleaned: an.cleaned() });
-- }
-- return { ok: reports.every((r) => r.ok), reports };
--}
--
--async function main(argv = process.argv.slice(2), out = console.log) {
-- const opt = (name) => { const i = argv.indexOf(`--${name}`); return i >= 0 ? argv[i + 1] : null; };
-- const flag = (name) => argv.includes(`--${name}`);
-- if (!opt('sqlite')) throw new Error('--sqlite <live.db> is required (production: /opt/openvibe.live/shared/data/live.db)');
-- const sqlite = path.resolve(opt('sqlite'));
-- // OpenVibe.Host's `ovhost data switch live` passes only --sqlite (its backup copy of live.db); the analytics file is
-- // read where it is, under the unit's DATA_DIR, while the service is stopped.
-- const fs = require('fs');
-- // The unit sets no DATA_DIR (Live's data is ./data in its release, a link to shared/data), and the switch runs this
-- // from a fresh clone: look under DATA_DIR, then where production keeps it.
-- const candidates = [process.env.DATA_DIR && path.join(path.resolve(process.env.DATA_DIR), 'analytics.db'),
-- '/opt/openvibe.live/shared/data/analytics.db', path.resolve('data', 'analytics.db')].filter(Boolean);
-- const analytics = opt('analytics') ? path.resolve(opt('analytics')) : (candidates.find((f) => fs.existsSync(f)) || null);
-- if (!analytics) console.warn('migrate-to-postgres: no analytics.db found; page analytics start empty');
-- const quiet = { log() {}, warn: console.warn, error: console.error };
-- let owner;
-- if (flag('pglite')) owner = createDb({ pglite: true, service: 'live-import', log: quiet });
-- else {
-- if (!process.env.DATABASE_DIRECT_URL) throw new Error('DATABASE_DIRECT_URL is not set: the import runs as the owner on a direct connection');
-- owner = createDb({ url: process.env.DATABASE_DIRECT_URL, service: 'live-import', max: 2, queryTimeoutMs: 3600e3, log: quiet });
-- }
-- try {
-- const t0 = Date.now();
-- const result = await importInto(owner, { sqlite, analytics, log: quiet });
-- if (flag('json')) out(JSON.stringify({ into: owner.store, ...result }, null, 2));
-- else {
-- for (const r of result.reports) {
-- out(`import ${r.source} → ${owner.store}: ${r.ok ? 'OK' : 'PROBLEMS'}`);
-- for (const t of r.tables) out(` ${t.table.padEnd(34)} ${String(t.rows).padStart(9)} rows ${t.checksum || '-'}`);
-- for (const c of r.cleaned) out(` cleaned: ${c.column}: ${c.values} value(s) with a NUL or an unpaired surrogate`);
-- for (const p of r.problems) out(` problem: ${p.table}: ${p.problem}`);
-- }
-- out(`${result.ok ? 'OK' : 'PROBLEMS'} in ${Math.round((Date.now() - t0) / 1000)} s`);
-- }
-- return result.ok ? 0 : 1;
-- } finally {
-- await owner.close();
-- }
--}
--
--if (require.main === module) {
-- main().then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
--}
--
--module.exports = { TABLES, SKIP_SOURCE, ANALYTICS_SKIP_SOURCE, MIGRATIONS, importInto };
ddiff --git a/scripts/n-1-record.js b/scripts/n-1-record.js
iindex de4b851d..de264004 100644
---- a/scripts/n-1-record.js
++++ b/scripts/n-1-record.js
@@@ -16,7 +16,6 @@
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');
@@@ -27,10 +26,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
const ref = process.argv[2] || 'HEAD';
const wt = h.worktree(ROOT, ref);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-record-'));
-- const dbPath = path.join(tmp, 'db', `${svc.service}.db`);
const 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 {
@@@ -43,8 +39,8 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
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 });
++ await svc.seed({ dir: wt.dir, dataDir });
++ server = await svc.boot({ dir: wt.dir, dataDir });
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 });
@@@ -98,41 +94,16 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
await server.close();
server = null;
-- // 3. The schema N-1 left, and the SQL it runs on it. A release on PostgreSQL (ADR-035) records its migration files
-- // instead: N must keep every one of them unchanged and only add (the database's own ledger refuses an edit).
++ // 3. Preserve the previous release's migration files for the N-1 schema check.
const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };
const clients = svc.clientReleases ? svc.clientReleases() : null;
const migDir = path.join(wt.dir, 'migrations');
-- if (fs.existsSync(migDir)) {
-- const migrations = fs.readdirSync(migDir).filter((f) => f.endsWith('.sql')).sort()
-- .map((name) => ({ name, sha256: require('crypto').createHash('sha256').update(fs.readFileSync(path.join(migDir, name))).digest('hex') }));
-- fs.mkdirSync(OUT, { recursive: true });
-- 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, engine: 'postgresql', migrations, statements: [] }, null, 1)}\n`);
-- console.log(` postgresql: ${migrations.length} migration file(s); wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
-- return;
-- }
-- 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`);
--
++ const migrations = fs.readdirSync(migDir).filter((f) => f.endsWith('.sql')).sort()
++ .map((name) => ({ name, sha256: require('crypto').createHash('sha256').update(fs.readFileSync(path.join(migDir, name))).digest('hex') }));
fs.mkdirSync(OUT, { recursive: true });
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`);
++ fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, engine: 'postgresql', migrations, statements: [] }, null, 1)}\n`);
++ console.log(` postgresql: ${migrations.length} migration file(s); wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
} finally {
if (server) await server.close();
wt.remove();
ddiff --git a/scripts/reset-platform.sh b/scripts/reset-platform.sh
ddeleted file mode 100755
iindex 05974dc5..00000000
---- a/scripts/reset-platform.sh
++++ /dev/null
@@@ -1,213 +0,0 @@
--#!/usr/bin/env bash
--# ══════════════════════════════════════════════════════════════
--# OpenVibe.Live — Full Platform Reset
--# ══════════════════════════════════════════════════════════════
--#
--# Wipes ALL persistent data:
--# • SQLite database (users, channels, streams, game, etc.)
--# • VODs, clips, thumbnails, emotes, media
--# • Remux marker files
--# • Optionally rotates JWT secret (invalidates all tokens)
--#
--# On next startup the server will:
--# • Rebuild all schemas from scratch
--# • Seed default site_settings + built-in themes
--# • Re-create admin user from .env credentials
--# • Generate a fresh OpenVibeGame world seed
--#
--# Usage:
--# ./scripts/reset-platform.sh # interactive confirmation
--# ./scripts/reset-platform.sh --force # skip confirmation
--# ./scripts/reset-platform.sh --help # show this help
--#
--# ══════════════════════════════════════════════════════════════
--
--set -euo pipefail
--
--SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
--ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
--DATA_DIR="$ROOT_DIR/data"
--ENV_FILE="$ROOT_DIR/.env"
--
--RED='\033[0;31m'
--GREEN='\033[0;32m'
--YELLOW='\033[1;33m'
--CYAN='\033[0;36m'
--BOLD='\033[1m'
--NC='\033[0m' # No Color
--
--print_banner() {
-- echo -e "${RED}"
-- echo " ╔══════════════════════════════════════════╗"
-- echo " ║ ⚠️ FULL PLATFORM RESET ⚠️ ║"
-- echo " ║ This will DELETE everything. ║"
-- echo " ╚══════════════════════════════════════════╝"
-- echo -e "${NC}"
--}
--
--print_help() {
-- echo "Usage: $0 [OPTIONS]"
-- echo ""
-- echo "Options:"
-- echo " --force Skip confirmation prompt"
-- echo " --rotate-jwt Also generate a new JWT secret in .env"
-- echo " --keep-media Keep VODs, clips, and thumbnails"
-- echo " --help Show this help message"
-- echo ""
-- echo "What gets deleted:"
-- echo " • Database: data/live.db (all users, channels, game data)"
-- echo " • VODs: data/vods/*"
-- echo " • Clips: data/clips/*"
-- echo " • Thumbnails: data/thumbnails/*"
-- echo " • Emotes: data/emotes/*"
-- echo " • Media: data/media/*"
-- echo " • Markers: data/vods/.remux-done"
-- echo ""
-- echo "What is preserved:"
-- echo " • .env config (admin creds, ports, secrets)"
-- echo " • All application code (server/, public/, etc.)"
-- echo " • Node modules"
--}
--
--# ── Parse args ────────────────────────────────────────────────
--
--FORCE=false
--ROTATE_JWT=false
--KEEP_MEDIA=false
--
--for arg in "$@"; do
-- case "$arg" in
-- --force) FORCE=true ;;
-- --rotate-jwt) ROTATE_JWT=true ;;
-- --keep-media) KEEP_MEDIA=true ;;
-- --help|-h) print_help; exit 0 ;;
-- *) echo -e "${RED}Unknown option: $arg${NC}"; print_help; exit 1 ;;
-- esac
--done
--
--# ── Safety checks ─────────────────────────────────────────────
--
--if [[ ! -f "$ROOT_DIR/package.json" ]]; then
-- echo -e "${RED}Error: Cannot find package.json. Run this from the openvibelive directory.${NC}"
-- exit 1
--fi
--
--# Check if server is running
--if pgrep -f "node.*server/index.js" > /dev/null 2>&1; then
-- echo -e "${YELLOW}⚠ Server appears to be running! Stop it first (Ctrl+C or kill the process).${NC}"
-- if [[ "$FORCE" == false ]]; then
-- read -rp "Continue anyway? This may corrupt the database. [y/N] " yn
-- [[ "$yn" =~ ^[Yy]$ ]] || exit 1
-- fi
--fi
--
--# ── Show what will be deleted ─────────────────────────────────
--
--print_banner
--
--echo -e "${BOLD}The following will be permanently deleted:${NC}"
--echo ""
--
--# Database
--DB_FILE="$DATA_DIR/live.db"
--if [[ -f "$DB_FILE" ]]; then
-- DB_SIZE=$(du -sh "$DB_FILE" 2>/dev/null | cut -f1)
-- echo -e " ${CYAN}Database${NC} $DB_FILE ($DB_SIZE)"
--
-- # Count some records if sqlite3 is available
-- if command -v sqlite3 &>/dev/null; then
-- USERS=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM users;" 2>/dev/null || echo "?")
-- GAME_P=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM game_players;" 2>/dev/null || echo "?")
-- MSGS=$(sqlite3 "$DB_FILE" "SELECT COUNT(*) FROM chat_messages;" 2>/dev/null || echo "?")
-- echo -e " ${YELLOW}→ $USERS users, $GAME_P game players, $MSGS chat messages${NC}"
-- fi
--else
-- echo -e " ${CYAN}Database${NC} (not found — nothing to delete)"
--fi
--
--# Media files
--if [[ "$KEEP_MEDIA" == false ]]; then
-- for dir in vods clips thumbnails emotes media; do
-- FULL="$DATA_DIR/$dir"
-- if [[ -d "$FULL" ]]; then
-- COUNT=$(find "$FULL" -type f ! -name '.gitkeep' 2>/dev/null | wc -l)
-- SIZE=$(du -sh "$FULL" 2>/dev/null | cut -f1)
-- echo -e " ${CYAN}$dir/${NC}$(printf '%*s' $((13 - ${#dir})) '') $COUNT files ($SIZE)"
-- fi
-- done
--else
-- echo -e " ${GREEN}(media files will be kept — --keep-media)${NC}"
--fi
--
--if [[ "$ROTATE_JWT" == true ]]; then
-- echo -e " ${CYAN}JWT Secret${NC} Will be rotated (invalidates all existing tokens)"
--fi
--
--echo ""
--
--# ── Confirm ───────────────────────────────────────────────────
--
--if [[ "$FORCE" == false ]]; then
-- echo -e "${RED}${BOLD}This action cannot be undone!${NC}"
-- read -rp "Type 'RESET' to confirm: " confirm
-- if [[ "$confirm" != "RESET" ]]; then
-- echo "Aborted."
-- exit 0
-- fi
-- echo ""
--fi
--
--# ── Execute reset ─────────────────────────────────────────────
--
--echo -e "${BOLD}Resetting platform...${NC}"
--
--# 1. Delete database
--echo -n " Removing database... "
--rm -f "$DATA_DIR/live.db" \
-- "$DATA_DIR/live.db-wal" \
-- "$DATA_DIR/live.db-shm"
--echo -e "${GREEN}done${NC}"
--
--# 2. Delete media content
--if [[ "$KEEP_MEDIA" == false ]]; then
-- for dir in vods clips thumbnails emotes media; do
-- FULL="$DATA_DIR/$dir"
-- if [[ -d "$FULL" ]]; then
-- echo -n " Clearing $dir/... "
-- find "$FULL" -type f ! -name '.gitkeep' -delete 2>/dev/null || true
-- echo -e "${GREEN}done${NC}"
-- fi
-- done
--else
-- echo -e " ${GREEN}Skipping media files (--keep-media)${NC}"
--fi
--
--# 3. Delete marker files
--echo -n " Removing marker files... "
--rm -f "$DATA_DIR/vods/.remux-done"
--echo -e "${GREEN}done${NC}"
--
--# 4. Rotate JWT secret (optional)
--if [[ "$ROTATE_JWT" == true ]]; then
-- echo -n " Rotating JWT secret... "
-- if [[ -f "$ENV_FILE" ]]; then
-- NEW_SECRET=$(openssl rand -hex 32 2>/dev/null || head -c 64 /dev/urandom | base64 | tr -d '/+=' | head -c 64)
-- sed -i "s|^JWT_SECRET=.*|JWT_SECRET=$NEW_SECRET|" "$ENV_FILE"
-- echo -e "${GREEN}done${NC}"
-- else
-- echo -e "${YELLOW}skipped (.env not found)${NC}"
-- fi
--fi
--
--# ── Done ──────────────────────────────────────────────────────
--
--echo ""
--echo -e "${GREEN}${BOLD}✅ Platform reset complete!${NC}"
--echo ""
--echo "On next startup, the server will:"
--echo " • Rebuild all database schemas"
--echo " • Seed default site settings & themes"
--echo " • Re-create admin user from .env"
--echo " • Generate a fresh OpenVibeGame world"
--echo ""
--echo -e "Start the server with: ${CYAN}cd $ROOT_DIR && npm start${NC}"
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 926abc3e..522cadf5 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -76,9 +76,8 @@ async function seedControlPresetsForUser(userId) {
* OpenVibe.Live — Database Connection & Helpers
* PostgreSQL through openvibe-sdk/db (ADR-035, plan T4). The schema is migrations/NNNN_*.sql, run with the owner role
* (DATABASE_DIRECT_URL) when the process boots; this module then opens the serving pool (DATABASE_URL, through
-- * PgBouncer). Every helper is async: statements keep better-sqlite3's shape (db.prepare(sql).get/all/run with ?
-- * parameters) and must be awaited. SQLite's 'YYYY-MM-DD HH:MM:SS' text timestamps stay as they were (datetime(),
-- * julianday() and ov_now() exist in the database: migrations/0002_live.sql).
++ * PgBouncer). Every helper is async and must be awaited. Queries use ? parameters; compatibility functions
++ * datetime(), julianday() and ov_now() are defined in migrations/0002_live.sql.
*/
const path = require('path');
const fs = require('fs');
@@@ -1271,7 +1270,7 @@ async function countRecentlyOnlineStreamers() {
// Public-facing site totals for the home hero stats bar.
let _homeStatsCache = null;
let _homeStatsCacheAt = 0;
--const _HOME_STATS_TTL = 30 * 1000; // 30s memo so the windowed COUNTs don't hammer SQLite
++const _HOME_STATS_TTL = 30 * 1000; // 30s memo for the windowed COUNTs
// ── Viewer trend sampling (home hero sparkline) ──────────────
// One row every ~5 minutes: total native viewers + live stream count.
ddiff --git a/server/diagnostics.js b/server/diagnostics.js
iindex 0d9a4048..f094700e 100644
---- a/server/diagnostics.js
++++ b/server/diagnostics.js
@@@ -57,12 +57,6 @@ async function snapshot(services = {}) {
jobs: await safe(() => require('./utils/jobs').snapshot(), []),
workQueues: await safe(() => require('./utils/limit').snapshot(), []),
migrations: await safe(async () => await require('./db/migrations').getStatus(db.getDb()), []),
-- sqlite: await safe(() => {
-- const fs = require('fs');
-- const file = db.getDb().name;
-- const size = (p) => { try { return mb(fs.statSync(p).size); } catch { return 0; } };
-- return { dbMb: size(file), walMb: size(`${file}-wal`) };
-- }),
};
}
ddiff --git a/server/events/stream-events.js b/server/events/stream-events.js
iindex 1774732c..8ada26e5 100644
---- a/server/events/stream-events.js
++++ b/server/events/stream-events.js
@@@ -5,7 +5,7 @@
* live.stream.started a stream row went live (subject: stream <id>, revision 1)
* live.stream.ended a live stream row ended (subject: stream <id>, revision 2)
*
-- * Events are written to Live's own `event_outbox` table inside the same SQLite transaction as the
++ * Events are written to Live's own `event_outbox` table inside the same database transaction as the
* streams row (database.js onStreamLifecycle), and a relay publishes them with Live's service
* token (audience openvibe.events, capability events.event.publish). If Events or Network is down
* the rows wait and are retried with backoff; going live never waits on either.
ddiff --git a/server/news/news-service.js b/server/news/news-service.js
iindex 9baabe44..13de1f30 100644
---- a/server/news/news-service.js
++++ b/server/news/news-service.js
@@@ -8,7 +8,7 @@
* news-service (this) ←→ sources (reddit, newsapi, rss, ...)
* → chat-delivery.js event() (Chat's ingress)
*
-- * Settings stored in SQLite: news_settings table (per-user/global).
++ * Settings stored in PostgreSQL: news_settings table (per-user/global).
* Headline dedup via in-memory Set (cleared on restart, bounded to 500).
*/
'use strict';
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex e51f5a16..7ed1621e 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -29,7 +29,7 @@
* Everything else is 404. Server routes (API, docs, overlays, popouts, legal pages, static files)
* answer before the fallback and are not affected.
*
-- * Cost: the home page and the fixed routes are a table lookup. Users and streams are one SQLite
++ * Cost: the home page and the fixed routes are a table lookup. Users and streams are one database
* read. Media items and pastes are fetched once per id per minute (concurrent requests share the
* fetch) and a visitor waits at most LOOKUP_DEADLINE_MS for one. When Media or Community cannot
* answer in time, the page answers 200: an outage upstream must never turn real pages into 404s.
ddiff --git a/setup-guide.html b/setup-guide.html
iindex e697e7da..19b93275 100644
---- a/setup-guide.html
++++ b/setup-guide.html
@@@ -332,7 +332,7 @@ sudo apt autoremove -y</code></pre>
<h3>Install baseline packages</h3>
<pre><code class="language-bash">sudo apt install -y \
git curl wget unzip ca-certificates gnupg \
-- nginx ffmpeg ufw fail2ban sqlite3 \
++ nginx ffmpeg ufw fail2ban \
build-essential python3 python3-pip jq</code></pre>
<h3>Optional: create a dedicated app user</h3>
@@@ -412,7 +412,8 @@ PORT=3000
BASE_URL=https://your-domain.example
JWT_SECRET=replace-with-a-long-random-secret
--DB_PATH=./data/live.db
++DATABASE_URL=postgres://live_runtime:password@localhost:5432/ov_live
++DATABASE_DIRECT_URL=postgres://live_owner:password@localhost:5432/ov_live
RTMP_PORT=1935
JSMPEG_VIDEO_PORT=9710
@@@ -762,9 +763,8 @@ sudo systemctl status openvibelive</code></pre>
<section id="close-reg" class="fade-in">
<h2><i class="fa-solid fa-user-lock"></i> 21. Close registration after bootstrap</h2>
<p>After you create your initial admin and trusted test accounts, close public registration. This is one of the cheapest and strongest anti-abuse controls in the project.</p>
--<pre><code class="language-bash">cd /opt/openvibelive
--sqlite3 data/live.db "UPDATE site_settings SET value='false' WHERE key='registration_open';"
--sqlite3 data/live.db "SELECT key, value FROM site_settings WHERE key='registration_open';"</code></pre>
++<pre><code class="language-bash">cd /opt/openvibe.live/current
++# Use the site administration settings to close registration.</code></pre>
</section>
<!-- ═══════════════ 22. fail2ban ═══════════════ -->
@@@ -911,7 +911,7 @@ sudo ufw status verbose</code></pre>
<h3>Best overall pick — B3-32</h3>
<ul>
--<li>32 GB RAM — enough for Node, SQLite, WebSockets, chat, thumbnails, clips, and moderate media workload</li>
++<li>32 GB RAM — enough for Node, PostgreSQL, WebSockets, chat, thumbnails, clips, and moderate media workload</li>
<li>8 vCores — sane floor for a mixed single-box deployment</li>
<li>200 GB NVMe — much more realistic than 50–100 GB once VODs and clips start accumulating</li>
<li>2 Gbit/s — leaves real headroom for 200 viewers at practical streaming bitrates</li>
@@@ -961,7 +961,7 @@ sudo ufw status verbose</code></pre>
</ul>
<h3>Storage dominates once VODs are enabled</h3>
--<p>The codebase stores locally: SQLite database, VODs, clips, thumbnails, emotes, avatars, and media assets. The OS and app don't need much space — <strong>recorded video does.</strong></p>
++<p>The service uses PostgreSQL for its database and OpenVibe.Media for VODs, clips, thumbnails, and media assets. The OS and app don't need much space — <strong>recorded video does.</strong></p>
</section>
<!-- ═══════════════ OVH Bandwidth ═══════════════ -->
ddiff --git a/test/deploy-sim.test.js b/test/deploy-sim.test.js
ddeleted file mode 100644
iindex df14e45b..00000000
---- a/test/deploy-sim.test.js
++++ /dev/null
@@@ -1,240 +0,0 @@
--/**
-- * deploy/scripts/deploy-legacy.sh (the release-layout deploy script from before `ovhost deploy live`;
-- * deploy/scripts/deploy.sh now hands over to ovhost and falls back to it) against a simulated host.
-- *
-- * A temp directory stands in for /opt/openvibe.live with a real git "origin", the release layout
-- * (repo, releases/<id>, current, shared/data), a fake `systemctl` that runs a tiny app, and a fake
-- * `npm` that writes a marker into node_modules. It proves the deploy claims instead of asserting them:
-- * - a public/-only change switches `current` WITHOUT restarting the process;
-- * - a server/ change restarts it;
-- * - a release that never becomes ready is rolled back automatically (exit 3), still serving;
-- * - --rollback returns to the previous release, restarting only if server code differs;
-- * - a lockfile change installs into the NEW release, so rolling back gets the OLD node_modules;
-- * - the legacy in-place layout also skips the restart for public/-only changes;
-- * - every deploy or rollback that went live runs `ovhost announce live` (a fake ovhost records it),
-- * one that did not go live or changed nothing does not, and a failing or older ovhost never fails a deploy.
-- *
-- * node test/deploy-sim.test.js
-- */
--'use strict';
--const assert = require('assert');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const http = require('http');
--const { execFileSync, spawnSync } = require('child_process');
--
--const DEPLOY = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh');
--const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-deploy-sim-'));
--const PORT = 20000 + Math.floor(Math.random() * 20000);
--const sh = (cmd, opts = {}) => execFileSync('bash', ['-c', cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
--
--let failures = 0;
--async function check(name, fn) {
-- try { await fn(); console.log(' ✓', name); }
-- catch (e) { failures++; console.log(' ✗', name, '\n ', String(e.message).split('\n').slice(0, 12).join('\n ')); }
--}
--const get = (p) => new Promise((resolve) => {
-- http.get({ port: PORT, path: p, timeout: 2000 }, (r) => { let b = ''; r.on('data', (c) => { b += c; }); r.on('end', () => resolve({ status: r.statusCode, body: b })); })
-- .on('error', () => resolve({ status: 0, body: '' }));
--});
--
--// ── The fake app: reads its "static" file through OV_APP_ROOT like the real server ──
--const APP = `
--const http = require('http'); const fs = require('fs'); const path = require('path');
--const root = process.env.OV_APP_ROOT;
--const broken = fs.existsSync(path.join(__dirname, 'BROKEN'));
--http.createServer((req, res) => {
-- if (req.url === '/api/ready') { res.statusCode = broken ? 503 : 200; return res.end('ok'); }
-- if (req.url === '/api/streams') return res.end('{"streams":[]}');
-- if (req.url === '/api/health') return res.end('{}');
-- if (req.url === '/static') return res.end(fs.readFileSync(path.join(root, 'public/x.txt'), 'utf8'));
-- if (req.url === '/code') return res.end(fs.readFileSync(path.join(__dirname, 'version.txt'), 'utf8').trim() + ' pid=' + process.pid);
-- if (req.url === '/deps') { try { return res.end(fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'marker'), 'utf8')); } catch { return res.end('none'); } }
-- res.statusCode = 404; res.end();
--}).listen(${PORT}, '127.0.0.1');
--`;
--
--(async () => {
-- const origin = path.join(tmp, 'origin.git');
-- const work = path.join(tmp, 'work');
-- const base = path.join(tmp, 'opt');
-- const bin = path.join(tmp, 'bin');
-- fs.mkdirSync(bin, { recursive: true });
--
-- // Author repo → bare origin.
-- sh(`git init -q -b main "${work}" && cd "${work}" && git config user.email t@t && git config user.name t`);
-- const write = (rel, content) => { fs.mkdirSync(path.dirname(path.join(work, rel)), { recursive: true }); fs.writeFileSync(path.join(work, rel), content); };
-- const commit = (msg) => sh(`cd "${work}" && git add -A && git commit -qm "${msg}" && git push -q origin main 2>/dev/null || (git remote add origin "${origin}" 2>/dev/null; git push -q origin main)`);
-- write('server/index.js', APP);
-- write('server/version.txt', 'v1');
-- write('public/x.txt', 'static-1');
-- write('package.json', JSON.stringify({ name: 'sim', dependencies: { a: '1' } }));
-- write('package-lock.json', JSON.stringify({ lockfileVersion: 3, v: 1 }));
-- sh(`git init -q --bare -b main "${origin}"`);
-- commit('v1');
--
-- // Fake systemctl: runs the current release's server in the background; `is-active` checks the pid.
-- const pidFile = path.join(tmp, 'app.pid');
-- fs.writeFileSync(path.join(bin, 'systemctl'), `#!/usr/bin/env bash
--PID_FILE="${pidFile}"
--case "$1" in
-- restart|start)
-- if [ -f "$PID_FILE" ]; then kill "$(cat "$PID_FILE")" 2>/dev/null; sleep 0.3; fi
-- OV_APP_ROOT="${base}/current" nohup node "${base}/current/server/index.js" >/dev/null 2>&1 &
-- echo $! > "$PID_FILE"; exit 0 ;;
-- stop) [ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null; exit 0 ;;
-- is-active) shift; [ "$1" = "--quiet" ] && shift; case "$1" in *.socket) exit 1 ;; esac
-- [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null ;;
-- is-enabled|list-unit-files) exit 1 ;;
-- *) exit 0 ;;
--esac
--`, { mode: 0o755 });
-- fs.writeFileSync(path.join(bin, 'npm'), `#!/usr/bin/env bash\nmkdir -p node_modules && echo "lock-$(node -e 'console.log(require(\"./package-lock.json\").v)')" > node_modules/marker\n`, { mode: 0o755 });
-- fs.writeFileSync(path.join(bin, 'journalctl'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
-- // Fake ovhost: records each call. OVHOST_FAKE_EXIT makes announce fail; OVHOST_FAKE_OLD drops announce from --help.
-- const ovhostLog = path.join(tmp, 'ovhost.log');
-- fs.writeFileSync(path.join(bin, 'ovhost'), `#!/usr/bin/env bash
--if [ "$1" = "--help" ]; then if [ -n "$OVHOST_FAKE_OLD" ]; then echo " deploy <service>"; else echo " announce <service> [--release <id>]"; fi; exit 0; fi
--echo "$*" >> "${ovhostLog}"
--echo "[ovhost] release notification sent: $2"
--exit "\${OVHOST_FAKE_EXIT:-0}"
--`, { mode: 0o755 });
-- const announced = () => { try { return fs.readFileSync(ovhostLog, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
--
-- // Release layout, as migrate-to-releases.sh leaves it.
-- fs.mkdirSync(path.join(base, 'releases'), { recursive: true });
-- fs.mkdirSync(path.join(base, 'shared', 'data'), { recursive: true });
-- sh(`git clone -q --no-checkout "${origin}" "${base}/repo"`);
-- const firstId = 'r0';
-- sh(`git -C "${base}/repo" worktree add -q --detach "${base}/releases/${firstId}" origin/main && cd "${base}/releases/${firstId}" && "${bin}/npm" && ln -s ../../shared/data data && ln -s releases/${firstId} "${base}/current"`);
-- const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, BASE_DIR: base, SYSTEMCTL: path.join(bin, 'systemctl'), NPM: path.join(bin, 'npm'), API_URL: `http://127.0.0.1:${PORT}`, SYSTEMD_DIR: path.join(tmp, 'systemd'), READY_TIMEOUT: '6', LOCK_FILE: path.join(tmp, 'lock'), BROADCAST_ENDPOINT: '/nope' };
-- const deploy = (...args) => spawnSync('bash', [DEPLOY, ...args], { env, encoding: 'utf8' });
-- sh(`"${bin}/systemctl" start x`, { env });
-- await new Promise((r) => setTimeout(r, 800));
-- const pid = async () => (await get('/code')).body.split('pid=')[1];
--
-- await check('initial release serves', async () => {
-- assert.strictEqual((await get('/static')).body, 'static-1');
-- assert.ok((await get('/code')).body.startsWith('v1'));
-- });
--
-- await check('public/-only change: current switches, process is NOT restarted', async () => {
-- const before = await pid();
-- write('public/x.txt', 'static-2'); commit('static');
-- const r = deploy();
-- assert.strictEqual(r.status, 0, r.stdout + r.stderr);
-- assert.match(r.stdout, /without a restart/);
-- assert.deepStrictEqual(announced(), ['announce live'], 'the release notification ran once');
-- assert.match(r.stdout, /release notification sent: live/);
-- assert.strictEqual((await get('/static')).body, 'static-2');
-- assert.strictEqual(await pid(), before, 'pid changed');
-- });
--
-- await check('server/ change: restarted, new code serving', async () => {
-- const before = await pid();
-- write('server/version.txt', 'v2'); commit('server');
-- const r = deploy();
-- assert.strictEqual(r.status, 0, r.stdout + r.stderr);
-- assert.strictEqual(announced().length, 2);
-- await new Promise((res) => setTimeout(res, 300));
-- const code = (await get('/code')).body;
-- assert.ok(code.startsWith('v2'), code);
-- assert.notStrictEqual(await pid(), before);
-- });
--
-- await check('lockfile change installs into the new release only', async () => {
-- write('package-lock.json', JSON.stringify({ lockfileVersion: 3, v: 2 })); write('server/version.txt', 'v3'); commit('deps');
-- const r = deploy();
-- assert.strictEqual(r.status, 0, r.stdout + r.stderr);
-- assert.match(r.stdout, /installing into the new release/);
-- await new Promise((res) => setTimeout(res, 300));
-- assert.strictEqual((await get('/deps')).body.trim(), 'lock-2');
-- });
--
-- await check('--rollback returns code AND node_modules of the previous release', async () => {
-- const n = announced().length;
-- const r = deploy('--rollback');
-- assert.strictEqual(r.status, 0, r.stdout + r.stderr);
-- assert.strictEqual(announced().length, n + 1, 'a rollback is announced too');
-- await new Promise((res) => setTimeout(res, 500));
-- assert.ok((await get('/code')).body.startsWith('v2'));
-- assert.strictEqual((await get('/deps')).body.trim(), 'lock-1');
-- });
--
-- await check('a release that never becomes ready is rolled back automatically (exit 3)', async () => {
-- // Move forward again first so there is a clean "current" to fall back to.
-- write('server/version.txt', 'v4'); commit('v4');
-- assert.strictEqual(deploy().status, 0);
-- write('server/BROKEN', '1'); write('server/version.txt', 'v5-broken'); commit('broken');
-- const n = announced().length;
-- const r = deploy();
-- assert.strictEqual(r.status, 3, r.stdout + r.stderr);
-- assert.strictEqual(announced().length, n, 'a release that did not go live is not announced');
-- await new Promise((res) => setTimeout(res, 500));
-- const code = (await get('/code')).body;
-- assert.ok(code.startsWith('v4'), code);
-- assert.strictEqual((await get('/api/ready')).status, 200);
-- });
--
-- await check('nothing new: no action, exit 0', async () => {
-- // origin still has the broken commit; the deploy would try again, so first fix forward.
-- fs.unlinkSync(path.join(work, 'server/BROKEN')); write('server/version.txt', 'v6'); commit('fix');
-- assert.strictEqual(deploy().status, 0);
-- const n = announced().length;
-- const r = deploy();
-- assert.strictEqual(r.status, 0);
-- assert.match(r.stdout, /nothing to deploy/);
-- assert.strictEqual(announced().length, n, 'nothing new, nothing announced');
-- });
--
-- await check('a failing release notification, or an ovhost without announce, never fails a deploy', async () => {
-- write('public/x.txt', 'static-3'); commit('static 3');
-- const failing = spawnSync('bash', [DEPLOY], { env: { ...env, OVHOST_FAKE_EXIT: '2' }, encoding: 'utf8' });
-- assert.strictEqual(failing.status, 0, failing.stdout + failing.stderr);
-- assert.match(failing.stdout, /release notification not sent \(the deploy stands\)/);
-- write('public/x.txt', 'static-4'); commit('static 4');
-- const n = announced().length;
-- const old = spawnSync('bash', [DEPLOY], { env: { ...env, OVHOST_FAKE_OLD: '1' }, encoding: 'utf8' });
-- assert.strictEqual(old.status, 0, old.stdout + old.stderr);
-- assert.match(old.stdout, /release notification skipped: this ovhost has no announce/);
-- assert.strictEqual(announced().length, n);
-- write('public/x.txt', 'static-5'); commit('static 5');
-- const none = spawnSync('bash', [DEPLOY], { env: { ...env, OVHOST: path.join(tmp, 'no-such-ovhost') }, encoding: 'utf8' });
-- assert.strictEqual(none.status, 0, none.stdout + none.stderr);
-- assert.strictEqual(announced().length, n, 'no ovhost: nothing to call');
-- assert.doesNotMatch(none.stdout, /release notification/);
-- });
--
-- await check('old releases are pruned to KEEP_RELEASES', async () => {
-- const n = fs.readdirSync(path.join(base, 'releases')).length;
-- assert.ok(n <= 5, `${n} releases kept`);
-- });
--
-- // ── Legacy in-place layout ──
-- sh(`"${bin}/systemctl" stop x`, { env });
-- await new Promise((r) => setTimeout(r, 800)); // let the port close before the legacy app binds it
-- const legacy = path.join(tmp, 'legacy');
-- sh(`git clone -q "${origin}" "${legacy}" && cd "${legacy}" && git config user.email t@t && git config user.name t && "${bin}/npm"`);
-- fs.writeFileSync(path.join(bin, 'systemctl'), fs.readFileSync(path.join(bin, 'systemctl'), 'utf8').replaceAll(`${base}/current`, legacy));
-- const lenv = { ...env, BASE_DIR: legacy };
-- sh(`"${bin}/systemctl" start x`, { env: lenv });
-- await new Promise((r) => setTimeout(r, 800));
-- await check('legacy layout: public/-only change pulls without restarting', async () => {
-- const before = await pid();
-- write('public/x.txt', 'legacy-static'); commit('legacy static');
-- const n = announced().length;
-- const r = spawnSync('bash', [DEPLOY], { env: lenv, encoding: 'utf8' });
-- assert.strictEqual(r.status, 0, r.stdout + r.stderr);
-- assert.match(r.stdout, /no restart needed/);
-- assert.strictEqual(announced().length, n + 1, 'the legacy layout announces too');
-- assert.strictEqual((await get('/static')).body, 'legacy-static', r.stdout + r.stderr + ' ready=' + (await get('/api/ready')).status);
-- assert.strictEqual(await pid(), before);
-- });
--
-- sh(`"${bin}/systemctl" stop x`, { env: lenv });
-- fs.rmSync(tmp, { recursive: true, force: true });
-- if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
-- console.log('\ndeploy simulation: all checks passed');
-- process.exit(0);
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/deploy-wrapper.test.js b/test/deploy-wrapper.test.js
iindex e1e7e468..23405521 100644
---- a/test/deploy-wrapper.test.js
++++ b/test/deploy-wrapper.test.js
@@@ -1,14 +1,5 @@
/**
-- * deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy live` (OpenVibe.Host, strategy
-- * release-layout; roadmap WS-N task 11), with deploy-legacy.sh (the previous script, unchanged) as its
-- * fallback. A fake ovhost records what the wrapper asked for; a fake legacy script records the fallback.
-- * - the flags map: (none) → deploy live, --wait-idle, --restart, --rollback → rollback live,
-- * DRY_RUN=1 → plan live, READY_TIMEOUT → --ready-timeout;
-- * - --force (legacy: discard local changes) is refused on the ovhost path, never passed on;
-- * - the fallback runs, with the original arguments and a message, when ovhost is missing, has no
-- * `capabilities`, reports deploy-api 0, does not deploy live with release-layout, or OVHOST_LEGACY=1;
-- * - deploy-legacy.sh is the old script: it still holds the listener check and the socket restart.
-- *
++ * The wrapper maps deploy flags to ovhost and fails when ovhost cannot manage Live.
* node test/deploy-wrapper.test.js
*/
'use strict';
@@@ -33,12 +24,9 @@ fi
echo "ovhost $*" >> "${log}"
exit "\${FAKE_EXIT:-0}"
`, { mode: 0o755 });
--const legacy = path.join(tmp, 'legacy.sh');
--fs.writeFileSync(legacy, `#!/usr/bin/env bash\necho "legacy $* DRY_RUN=\${DRY_RUN:-}" >> "${log}"\nexit 0\n`, { mode: 0o755 });
--
const run = (args = [], env = {}) => {
fs.rmSync(log, { force: true });
-- const r = spawnSync('bash', [WRAPPER, ...args], { env: { PATH: process.env.PATH, OVHOST: ovhost, OVHOST_SUDO: '', DEPLOY_LEGACY: legacy, ...env }, encoding: 'utf8' });
++ const r = spawnSync('bash', [WRAPPER, ...args], { env: { PATH: process.env.PATH, OVHOST: ovhost, OVHOST_SUDO: '', ...env }, encoding: 'utf8' });
let calls = [];
try { calls = fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean); } catch { /* none */ }
return { code: r.status, out: r.stdout + r.stderr, calls };
@@@ -69,7 +57,7 @@ check('--force is refused on the ovhost path (it would drop live streams), never
const r = run(['--force']);
assert.strictEqual(r.code, 1, r.out);
assert.deepStrictEqual(r.calls, []);
-- assert.match(r.out, /--force meant 'discard local tracked changes'/);
++ assert.match(r.out, /--force drops live streams/);
});
check('an unknown flag is a usage error', () => {
@@@ -78,37 +66,33 @@ check('an unknown flag is a usage error', () => {
assert.deepStrictEqual(r.calls, []);
});
--check('the fallback runs deploy-legacy.sh with the original arguments, and says why', () => {
++check('missing or incompatible ovhost fails without deploying', () => {
let r = run(['--wait-idle'], { OVHOST: path.join(tmp, 'missing-ovhost') });
-- assert.strictEqual(r.code, 0, r.out);
-- assert.deepStrictEqual(r.calls, ['legacy --wait-idle DRY_RUN=']);
-- assert.match(r.out, /ovhost not found .* — running deploy-legacy\.sh/);
--
-- r = run(['--force'], { FAKE_OLD: '1' });
-- assert.deepStrictEqual(r.calls, ['legacy --force DRY_RUN='], 'the legacy script keeps its own --force');
-- assert.match(r.out, /no 'capabilities' \(too old\)/);
++ assert.strictEqual(r.code, 1, r.out);
++ assert.deepStrictEqual(r.calls, []);
++ assert.match(r.out, /ovhost not found/);
-- r = run([], { FAKE_CAPS: 'ovhost=0.2.9\\ndeploy-api=0\\nstrategy=release-layout\\nmanaged=yes' });
-- assert.deepStrictEqual(r.calls, ['legacy DRY_RUN=']);
-- assert.match(r.out, /deploy-api is 0, 1 is needed/);
++ r = run([], { FAKE_OLD: '1' });
++ assert.strictEqual(r.code, 1, r.out);
++ assert.deepStrictEqual(r.calls, []);
++ assert.match(r.out, /no 'capabilities'/);
-- r = run([], { FAKE_CAPS: 'ovhost=0.3.0\\ndeploy-api=1\\nstrategy=none\\nmanaged=no' });
-- assert.deepStrictEqual(r.calls, ['legacy DRY_RUN=']);
-- assert.match(r.out, /does not deploy live with strategy release-layout \(none\)/);
++ r = run([], { FAKE_CAPS: 'deploy-api=0\\nstrategy=release-layout\\nmanaged=yes' });
++ assert.strictEqual(r.code, 1, r.out);
++ assert.deepStrictEqual(r.calls, []);
++ assert.match(r.out, /deploy-api is 0/);
-- r = run(['--rollback'], { OVHOST_LEGACY: '1', DRY_RUN: '1' });
-- assert.deepStrictEqual(r.calls, ['legacy --rollback DRY_RUN=1']);
++ r = run([], { FAKE_CAPS: 'deploy-api=1\\nstrategy=none\\nmanaged=no' });
++ assert.strictEqual(r.code, 1, r.out);
++ assert.deepStrictEqual(r.calls, []);
++ assert.match(r.out, /strategy release-layout/);
});
--check('deploy-legacy.sh is the previous script: listener check, changed-socket restart, release layout', () => {
-- const text = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');
-- assert.match(text, /socket_held\(\)/);
-- assert.match(text, /SOCKET_CHANGED=true/);
-- assert.match(text, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/);
-- assert.match(text, /KEEP_RELEASES/);
++check('the wrapper has no fallback path', () => {
const wrapper = fs.readFileSync(WRAPPER, 'utf8');
-- assert.ok(wrapper.split('\n').length < 120, 'the wrapper stays thin');
++ assert.ok(wrapper.split('\n').length < 120);
assert.match(wrapper, /^set -euo pipefail$/m);
++ assert.doesNotMatch(wrapper, /LEGACY|exec bash/);
});
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/n-1.test.js b/test/n-1.test.js
iindex 7f8cab46..f9a752c8 100644
---- a/test/n-1.test.js
++++ b/test/n-1.test.js
@@@ -11,11 +11,8 @@
* 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.
++ * - N-1 migration history: test/fixtures/n-1/worker.json records the previous release's
++ * migration filenames and hashes. This release must preserve each file unchanged.
*
* 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.
@@@ -26,7 +23,6 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
--const Database = require('better-sqlite3');
const compat = require('openvibe-shared/release-compat');
const h = require('./n-1/harness');
const svc = require('./n-1/service');
@@@ -43,7 +39,7 @@ async function check(name, fn) {
(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: ${svc.service} N-1 = ${client.release.slice(0, 12)}, recorded ${client.recorded_at.slice(0, 10)} (${age} d ago); ${client.calls.length} calls, ${worker.migrations.length} migrations`);
console.log('n-1: the harness');
await check('finds call sites, their method and template, and skips computed paths', () => {
@@@ -63,53 +59,20 @@ async function check(name, fn) {
assert.deepStrictEqual(h.readProblems(reads, { streams: [{ title: 'b', viewer_count: '2' }] }), ['streams[].viewer_count was number, now string', 'total is gone']);
assert.ok(h.statusCompatible(200, 201) && h.statusCompatible(502, 503) && !h.statusCompatible(401, 403) && !h.statusCompatible(502, 404) && !h.statusCompatible(200, 404));
});
-- await check('SQL: a dropped column fails the prepare; a new NOT NULL column without a default fails an old INSERT', () => {
-- const d = new Database(':memory:');
-- d.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT NOT NULL, c TEXT NOT NULL DEFAULT \'x\')');
-- assert.deepStrictEqual(h.prepareProblems(d, ['SELECT a, gone FROM t']).map((p) => p.error), ['no such column: gone']);
-- assert.deepStrictEqual(h.insertProblems(d, ['INSERT INTO t (a, b) VALUES (?, ?)', 'INSERT INTO t (a) VALUES (?)']).map((p) => p.error), ['t.b is NOT NULL without a default, and N-1 does not set it']);
-- d.close();
-- });
--
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 });
-- // This release on PostgreSQL (ADR-035, plan T4) and a recorded N-1 that still ran on SQLite: they never share a
-- // database, so N-1's SQL has no schema of this release to run on. The client half still runs, on a freshly migrated
-- // and seeded database. Once the N-1 fixture is recorded from a PostgreSQL release: N keeps every migration N-1 ran.
-- const pg = fs.existsSync(path.join(ROOT, 'migrations', '0002_live.sql'));
-- const sqlSkip = pg && worker.engine !== 'postgresql' ? 'N-1 SQL: skipped (the recorded N-1 release runs on SQLite and this release on PostgreSQL: no shared database, so no N-1 statement runs on this schema)' : null;
-- if (pg && worker.engine === 'postgresql') {
-- await check(`N-1's ${worker.migrations.length} migration file(s) are all here, unchanged`, () => {
-- const dir = path.join(ROOT, 'migrations');
-- const hash = (f) => require('crypto').createHash('sha256').update(fs.readFileSync(path.join(dir, f))).digest('hex');
-- const bad = worker.migrations.filter((m) => !fs.existsSync(path.join(dir, m.name)) || hash(m.name) !== m.sha256).map((m) => m.name);
-- assert.deepStrictEqual(bad, [], 'a migration N-1 ran was removed or edited: write a new one instead');
-- });
-- }
++ await check(`N-1's ${worker.migrations.length} migration file(s) are all here, unchanged`, () => {
++ const dir = path.join(ROOT, 'migrations');
++ const hash = (f) => require('crypto').createHash('sha256').update(fs.readFileSync(path.join(dir, f))).digest('hex');
++ const bad = worker.migrations.filter((m) => !fs.existsSync(path.join(dir, m.name)) || hash(m.name) !== m.sha256).map((m) => m.name);
++ assert.deepStrictEqual(bad, [], 'a migration N-1 ran was removed or edited: write a new one instead');
++ });
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', () => {
-- if (pg) { svc.seed({ dir: ROOT, dbPath, dataDir }); return; }
-- 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 });
++ console.log(`n-1: this release on a migrated PGlite database`);
++ await check('this release migrates and seeds its database', () => svc.seed({ dir: ROOT, dataDir }));
++ server = await svc.boot({ dir: ROOT, dataDir });
const manifest = await (await fetch(`${server.url}/release.json`)).json();
console.log('n-1: the N-1 client against this server');
@@@ -140,17 +103,6 @@ async function check(name, fn) {
});
await server.close();
server = null;
--
-- if (sqlSkip) console.log(sqlSkip);
-- else if (!pg) {
-- 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 });
ddiff --git a/test/n-1/harness.js b/test/n-1/harness.js
iindex d22ba999..fe1eb8b3 100644
---- a/test/n-1/harness.js
++++ b/test/n-1/harness.js
@@@ -1,15 +1,15 @@
'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.
++ * against this release's server, with its migration files checked for stability.
* 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.
++ * its clients are and how to boot and seed it.
*
* 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
++ * test/n-1.test.js in npm test and CI: boots this checkout with PGlite,
++ * replays the fixture and checks migration hashes
*
* 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,
@@@ -18,8 +18,6 @@
* 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 path = require('path');
@@@ -668,87 +666,6 @@ function wsProblems(recorded, received) {
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 = [];
@@@ -768,50 +685,6 @@ function readTree(root, dirs, exts = ['.js']) {
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 checkout of `ref` sharing root's node_modules → { dir, sha, remove() }. test/helpers/old-release.js. */
@@@ -839,6 +712,6 @@ module.exports = {
flatten, readsOf, readProblems, statusCompatible,
send, requestsFor, notFoundProbe, isNoRoute, callProblems,
wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
-- stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
++ readTree,
worktree, gitFiles, summarize,
};
ddiff --git a/test/n-1/preload.js b/test/n-1/preload.js
iindex b650226c..8e458c92 100644
---- a/test/n-1/preload.js
++++ b/test/n-1/preload.js
@@@ -1,28 +1,6 @@
'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 (LIVE_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');
++/** Lift the restore drill's read-only guard so the N-1 client can exercise write routes. */
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();
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex ce2a9394..7197cdf1 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -37,25 +37,7 @@ function baseEnv(extra) {
return { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR || '/tmp', NODE_ENV: 'test', ...extra };
}
--const SEED = `
-- console.log = () => {}; console.warn = () => {};
-- const db = require('./server/db/database');
-- db.initDb();
-- const d = db.getDb();
-- const star = Number(db.createUser({ username: 'n1star', display_name: 'N1 Star', password_hash: 'x', stream_key: 'n1starkey' }).lastInsertRowid);
-- const fan = Number(db.createUser({ username: 'n1fan', display_name: 'N1 Fan', password_hash: 'x', stream_key: 'n1fankey' }).lastInsertRowid);
-- d.prepare("UPDATE users SET role = 'streamer', bio = 'Seeded for the N-1 test' WHERE id = ?").run(star);
-- db.ensureChannel(star); db.ensureChannel(fan);
-- d.prepare("INSERT INTO streams (user_id, title, category, protocol, is_live, viewer_count, started_at, last_heartbeat) VALUES (?, 'N-1 live stream', 'tech', 'webrtc', 1, 2, datetime('now', '-10 minutes'), datetime('now'))").run(star);
-- d.prepare("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at) VALUES (?, 'N-1 past stream', 'irl', 'webrtc', 0, datetime('now', '-2 days'), datetime('now', '-2 days', '+1 hour'))").run(star);
-- d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(fan, star);
-- db.close();
--`;
--
--// A release on PostgreSQL (plan T4: migrations/0002_live.sql): with no DATABASE_URL it opens an embedded PGlite database
--// under DATA_DIR, which the seed and then the drill-mode boot open one after the other (never both at once). On SQLite a
--// boot backfill gave each streamer a stream slot; this seed creates the slot itself.
--const isPg = (dir) => fs.existsSync(path.join(dir, 'migrations', '0002_live.sql'));
++// Development and test releases share the embedded PGlite database under DATA_DIR.
const SEED_PG = `
console.log = () => {}; console.warn = () => {};
const db = require('./server/db/database');
@@@ -108,24 +90,21 @@ module.exports = {
[/^q$|query|search|term/i, 'n1'],
],
-- sqlDirs: ['server'],
-- ledgerTables: ['schema_migrations'],
--
/** Seeds a database with the release in `dir` (its initDb migrates first). */
-- seed({ dir, dbPath, dataDir }) {
-- const r = spawnSync(process.execPath, ['-e', isPg(dir) ? SEED_PG : SEED], { cwd: dir, encoding: 'utf8', timeout: 120000, env: baseEnv({ DB_PATH: dbPath, DATA_DIR: dataDir }) });
++ seed({ dir, dataDir }) {
++ const r = spawnSync(process.execPath, ['-e', SEED_PG], { cwd: dir, encoding: 'utf8', timeout: 120000, env: baseEnv({ DATA_DIR: 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() }. */
-- async boot({ dir, dbPath, dataDir, sqlOut = '' }) {
++ async boot({ dir, dataDir }) {
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
fs.mkdirSync(path.join(dataDir, 'keys'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'keys', 'openvibe-tools-public.pem'), keys.publicKey);
const port = await freePort();
const child = spawn(process.execPath, ['-r', PRELOAD, 'server/index.js'], {
cwd: dir,
-- env: baseEnv({ LIVE_DRILL: '1', DB_PATH: dbPath, DATA_DIR: dataDir, HOST: '127.0.0.1', PORT: String(port), N1_SQL_OUT: sqlOut }),
++ env: baseEnv({ LIVE_DRILL: '1', DATA_DIR: dataDir, HOST: '127.0.0.1', PORT: String(port) }),
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
ddiff --git a/test/observability.test.js b/test/observability.test.js
iindex 44674dc8..c6e08c61 100644
---- a/test/observability.test.js
++++ b/test/observability.test.js
@@@ -8,7 +8,7 @@
* (200 "degraded", naming what is missing)
* - /release.json validates as registry.release-manifest@1 and names /release-metrics, whose beacons
* (text/plain or JSON, behind the global JSON parser) land in release_client_updates_total
-- * A temp SQLite database stands in for Live's; no Media, Network or mediasoup is needed.
++ * A migrated PGlite database stands in for Live's; no Media, Network or mediasoup is needed.
*
* Run: node test/observability.test.js
*/
ddiff --git a/test/systemd-units.test.js b/test/systemd-units.test.js
iindex 9fe1e4e4..f8317f73 100644
---- a/test/systemd-units.test.js
++++ b/test/systemd-units.test.js
@@@ -43,17 +43,10 @@ assert.strictEqual(load({ NODE_ENV: 'production', LISTEN_HOST: '10.0.0.5', BASE_
assert.strictEqual(load({ NODE_ENV: 'development' }).listenHost, '0.0.0.0', 'development keeps HOST (default 0.0.0.0)');
assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).host, '0.0.0.0', 'HOST still builds URLs');
--// Deploys check that systemd really holds the listener, and restart a socket unit that changed. Since
--// WS-N task 11 deploy.sh hands over to `ovhost deploy live` (OpenVibe.Host strategy release-layout:
--// systemd.socketHeld + rebindSocket, test/strategy-release-layout.test.js there) and falls back to
--// deploy-legacy.sh, the previous script, which still does it itself.
--const legacy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');
--assert.match(legacy, /socket_held\(\)/, 'deploy-legacy.sh has the listener check');
--assert.match(legacy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');
--assert.match(legacy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');
++// The wrapper only hands release-layout deploys to ovhost; its host strategy owns the socket rule.
const wrapper = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');
--assert.match(wrapper, /^STRATEGY=release-layout$/m, 'deploy.sh only hands over to an ovhost that deploys live with release-layout (the socket rule)');
--assert.match(wrapper, /deploy-api/, 'and checks the deploy API version before it does');
--assert.match(wrapper, /exec bash "\$LEGACY"/, 'anything else falls back to deploy-legacy.sh');
++assert.match(wrapper, /^STRATEGY=release-layout$/m);
++assert.match(wrapper, /deploy-api/);
++assert.doesNotMatch(wrapper, /LEGACY|exec bash/);
console.log('systemd units: all checks passed');