openvibers/openvibe.live Commits
Live: the migration era is deleted (SQLite migration and reset scripts, legacy deploy path, better-sqlite3, DB_PATH) (#72)
Changes
@@@ -36,9 +36,7 @@ BASE_URL=http://localhost:3000JWT_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@@@ -29,7 +29,7 @@ If you want **one OVH instance that makes the most sense for real OpenVibe.LiveWhy:--- 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@@@ -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.@@@ -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",@@@ -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 abuseddeleted file mode 100755@@@ -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@@@ -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 pipefailSERVICE=liveSTRATEGY=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=(); fisay() { echo "[Deploy] $*"; }--ORIG=("$@")CMD=deployFLAGS=()FORCE=false@@@ -57,15 +51,9 @@ while [ "$#" -gt 0 ]; dodone[ -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; fiif ! command -v "$OVHOST" >/dev/null 2>&1; then REASON="ovhost not found ($OVHOST)"; return 1; filocal caps apiif ! 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; fiif [ "$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." >&2exit 1fiif [ "${DRY_RUN:-0}" = 1 ]; thenddeleted file mode 100755@@@ -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)"@@@ -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, butddeleted file mode 100644@@@ -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.@@@ -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 thetables ([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: Livequeues `live.release.deployed` and Chat folds and stores the rolling card. `test/chat-tables-dropped.test.js`guards that nothing under `server/` names them.ddeleted file mode 100644@@@ -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.ddeleted file mode 100644@@@ -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: greenddeleted file mode 100644@@@ -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--```ddeleted file mode 100644@@@ -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@@@ -13,15 +13,11 @@ sudo deploy/scripts/deploy.sh --rollback # ovhost rollback live: the release tDRY_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 pagerendered 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 therelease `/release.json` now reports, once per release, to OpenVibe.Events. Open tabs (openvibe-shared1.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 isnothing 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 budgetsBASE=http://127.0.0.1:3000 npm run test:browser # needs a running server and Chromedeploy/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 deployFor 24 hours after a deploy (ADR-016) open tabs run the previous release's client against the newserver, 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 everyscript, stylesheet and link of the shell and a channel page it served, with the status, JSON-nessand 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:@@@ -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 |@@@ -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.```---ddeleted file mode 100644@@@ -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`.@@@ -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`@@@ -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` withLive's `community.paste.moderate` grant. Media's `/stats` and `/stats/series/pastes` still count Media'sfrozen 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);--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`.@@@ -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",@@@ -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"}}@@@ -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>ddeleted file mode 100644@@@ -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 };@@@ -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();ddeleted file mode 100755@@@ -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}"@@@ -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.@@@ -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`) };-- }),};}@@@ -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.@@@ -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';@@@ -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.@@@ -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=3000BASE_URL=https://your-domain.exampleJWT_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_liveRTMP_PORT=1935JSMPEG_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 ═══════════════ -->ddeleted file mode 100644@@@ -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); });@@@ -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 @@ fiecho "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), neverconst 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 });@@@ -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 });@@@ -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,};@@@ -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();@@@ -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 = '';@@@ -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*/@@@ -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');
@@ -36,9 +36,7 @@ BASE_URL=http://localhost:3000
@@ -36,9 +36,7 @@ BASE_URL=http://localhost:3000
JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars
JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars
# Database
# 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_URL=postgres://live_runtime:password@localhost:5432/ov_live
# DATABASE_DIRECT_URL=postgres://live_owner: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
# Every file Live writes lives under one data directory (default ./data, relative to the working
@@ -46,7 +44,7 @@ DB_PATH=./data/live.db
@@ -46,7 +44,7 @@ DB_PATH=./data/live.db
# DATA_DIR=./data
# DATA_DIR=./data
# Restore-drill mode, set only by `ovhost drill live` (OpenVibe.Host docs/restore-drills.md): reads
# 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
# 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).
# makes no outbound connection (server/drill.js).
# LIVE_DRILL=1
# LIVE_DRILL=1
@@ -29,7 +29,7 @@ If you want **one OVH instance that makes the most sense for real OpenVibe.Live
@@ -29,7 +29,7 @@ If you want **one OVH instance that makes the most sense for real OpenVibe.Live
Why:
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
- 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
- 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
- 2 Gbit/s leaves real headroom for 200 viewers at practical streaming bitrates
@@ -95,7 +95,7 @@ Relative server-side cost, roughly:
@@ -95,7 +95,7 @@ Relative server-side cost, roughly:
The current codebase stores locally:
The current codebase stores locally:
-- SQLite database
+- PostgreSQL database
- VODs
- VODs
- clips
- clips
- thumbnails
- thumbnails
@@ -179,7 +179,7 @@ You should reserve space for:
@@ -179,7 +179,7 @@ You should reserve space for:
- Ubuntu + packages + logs
- Ubuntu + packages + logs
- app code
- app code
-- SQLite DB
+- PostgreSQL data
- thumbnails, avatars, emotes
- thumbnails, avatars, emotes
- temporary files during clip/VOD work
- temporary files during clip/VOD work
@@ -54,7 +54,7 @@ The media subsystem lives in **OpenVibe.Media**:
@@ -54,7 +54,7 @@ The media subsystem lives in **OpenVibe.Media**:
### Data storage
### 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/live-thumbs` — ephemeral live-stream thumbnails.
- `data/emotes`, `data/avatars`, `data/offline` — Live-local assets.
- `data/emotes`, `data/avatars`, `data/offline` — Live-local assets.
- `data/media/cache` — song-request (watch-party) downloads.
- `data/media/cache` — song-request (watch-party) downloads.
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"repository": "OpenVibers/OpenVibe.Live",
"repository": "OpenVibers/OpenVibe.Live",
"stage": "stable",
"stage": "stable",
"code": true,
"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,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.126.0",
"contracts": "openvibe-contracts v0.126.0",
@@ -2,11 +2,9 @@
@@ -2,11 +2,9 @@
How deploys work: [../docs/deploy.md](../docs/deploy.md).
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
- `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`)
- `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)
- `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
- `fail2ban/jail.local.example` — starter fail2ban config for SSH and nginx abuse
deleted file mode 100755
deleted file mode 100755
@@ -1,478 +0,0 @@
@@ -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
@@ -17,13 +17,10 @@
@@ -17,13 +17,10 @@
# DRY_RUN=1 deploy/scripts/deploy.sh ovhost plan live (changes nothing)
# DRY_RUN=1 deploy/scripts/deploy.sh ovhost plan live (changes nothing)
# READY_TIMEOUT=<s> --ready-timeout <s>
# 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.
# `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
# 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 ·
# restarted · 3 not ready, rolled back · 4 rollback failed (manual intervention) · 5 protected sessions ·
@@ -33,15 +30,12 @@ set -euo pipefail
@@ -33,15 +30,12 @@ set -euo pipefail
SERVICE=live
SERVICE=live
STRATEGY=release-layout
STRATEGY=release-layout
-HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
-LEGACY="${DEPLOY_LEGACY:-$HERE/deploy-legacy.sh}"
OVHOST="${OVHOST:-/usr/local/bin/ovhost}"
OVHOST="${OVHOST:-/usr/local/bin/ovhost}"
# ovhost runs as root; tests set OVHOST_SUDO= to call it directly.
# 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
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] $*"; }
say() { echo "[Deploy] $*"; }
-ORIG=("$@")
CMD=deploy
CMD=deploy
FLAGS=()
FLAGS=()
FORCE=false
FORCE=false
@@ -57,15 +51,9 @@ while [ "$#" -gt 0 ]; do
@@ -57,15 +51,9 @@ while [ "$#" -gt 0 ]; do
done
done
[ -n "${READY_TIMEOUT:-}" ] && FLAGS+=(--ready-timeout "$READY_TIMEOUT")
[ -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.
# Does this ovhost deploy live with the strategy this wrapper hands over to? Sets REASON when not.
REASON=""
REASON=""
probe() {
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
if ! command -v "$OVHOST" >/dev/null 2>&1; then REASON="ovhost not found ($OVHOST)"; return 1; fi
local caps api
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
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() {
@@ -77,11 +65,11 @@ probe() {
return 0
return 0
}
}
-probe || legacy "$REASON"
+if ! probe; then echo "[Deploy] ✗ $REASON" >&2; exit 1; fi
if [ "$FORCE" = true ]; then
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
exit 1
fi
fi
if [ "${DRY_RUN:-0}" = 1 ]; then
if [ "${DRY_RUN:-0}" = 1 ]; then
deleted file mode 100755
deleted file mode 100755
@@ -1,86 +0,0 @@
@@ -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)"
@@ -1,5 +1,3 @@
@@ -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]
[Unit]
Description=OpenVibe.Live — Open Live Streaming, Community Run
Description=OpenVibe.Live — Open Live Streaming, Community Run
# Media (openvibe-media) and Network (openvibe-network) should be up first, but
# Media (openvibe-media) and Network (openvibe-network) should be up first, but
deleted file mode 100644
deleted file mode 100644
@@ -1,21 +0,0 @@
@@ -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.
@@ -79,8 +79,7 @@ path are deleted.
@@ -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)
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
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`
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`
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.
guards that nothing under `server/` names them.
deleted file mode 100644
deleted file mode 100644
@@ -1,71 +0,0 @@
@@ -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.
deleted file mode 100644
deleted file mode 100644
@@ -1,117 +0,0 @@
@@ -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
deleted file mode 100644
deleted file mode 100644
@@ -1,94 +0,0 @@
@@ -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
-```
deleted file mode 100644
deleted file mode 100644
@@ -1,83 +0,0 @@
@@ -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
@@ -13,15 +13,11 @@ sudo deploy/scripts/deploy.sh --rollback # ovhost rollback live: the release t
@@ -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
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
## What each kind of change costs
@@ -42,23 +38,15 @@ Clients reconnect with jittered backoff and show "OpenVibe is updating" / "Recon
@@ -42,23 +38,15 @@ Clients reconnect with jittered backoff and show "OpenVibe is updating" / "Recon
## Layouts
## 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/repo git clone used to create releases
/opt/openvibe.live/releases/<time>-<sha> worktree + its own node_modules + data -> ../../shared/data
/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/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
- 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`.
@@ -80,19 +68,14 @@ but could not restore previous `node_modules`.
- The last 5 releases are kept.
- The last 5 releases are kept.
- Content-hashed assets from the previous release are still served under their old hashes, so a page
- 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.
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
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
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`.
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
## Assets and caching
@@ -139,7 +122,7 @@ PGlite database under `DATA_DIR`). In that mode
@@ -139,7 +122,7 @@ PGlite database under `DATA_DIR`). In that mode
## Checks
## Checks
```bash
```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
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
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
@@ -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
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.
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
- `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
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
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:
After each deploy, record the release now in production as the next release's N-1 and commit it:
@@ -105,7 +105,7 @@ What did **not** improve: layout on the home page is dominated by the page's own
@@ -105,7 +105,7 @@ What did **not** improve: layout on the home page is dominated by the page's own
## Deploy
## 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 |
| 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 |
| server code | restart | restart, readiness-gated, automatic rollback |
@@ -153,7 +153,7 @@ The entire platform — streaming server, chat, media pipeline, game, everything
@@ -153,7 +153,7 @@ The entire platform — streaming server, chat, media pipeline, game, everything
⭐ https://github.com/OpenVibers
⭐ https://github.com/OpenVibers
-Built with Node.js, WebSockets, SQLite, FFmpeg, and mediasoup.
+Built with Node.js, WebSockets, PostgreSQL, FFmpeg, and mediasoup.
```
```
---
---
deleted file mode 100644
deleted file mode 100644
@@ -1,100 +0,0 @@
@@ -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`.
@@ -32,7 +32,7 @@ Persistent slot settings live in `managed_streams.broadcast_settings.vibe_coding
@@ -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_sessions` — one row per `managed_stream_id + session_key`
- `vibe_coding_events` — canonical sanitized events keyed by `managed_stream_id + event_id`
- `vibe_coding_events` — canonical sanitized events keyed by `managed_stream_id + event_id`
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
> Written before the media split. Storage, cutting, thumbnails and files now live in OpenVibe.Media;
> 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`).
> 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
> 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
## Recording System
@@ -149,141 +149,3 @@ and its rows moved to Media at the split.
@@ -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
- **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
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.
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);
-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`.
@@ -36,8 +36,7 @@
@@ -36,8 +36,7 @@
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8",
- "better-sqlite3": "^11.3.0"
+ "@electric-sql/pglite": "^0.5.8"
},
},
"engines": {
"engines": {
"node": ">=18.0.0"
"node": ">=18.0.0"
@@ -965,55 +964,6 @@
@@ -965,55 +964,6 @@
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
"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": {
"node_modules/body-parser": {
"version": "1.20.6",
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
@@ -1038,31 +988,6 @@
@@ -1038,31 +988,6 @@
"npm": "1.2.8000 || >= 1.4.16"
"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": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -1310,32 +1235,6 @@
@@ -1310,32 +1235,6 @@
"ms": "2.0.0"
"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": {
"node_modules/deepmerge": {
"version": "4.3.1",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -1550,16 +1449,6 @@
@@ -1550,16 +1449,6 @@
"node": ">= 0.8"
"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": {
"node_modules/entities": {
"version": "8.1.0",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
@@ -1639,16 +1528,6 @@
@@ -1639,16 +1528,6 @@
"node": ">=0.8.x"
"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": {
"node_modules/express": {
"version": "4.22.2",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -1767,13 +1646,6 @@
@@ -1767,13 +1646,6 @@
"node": "^12.20 || >= 14.13"
"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": {
"node_modules/finalhandler": {
"version": "1.3.2",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1828,13 +1700,6 @@
@@ -1828,13 +1700,6 @@
"node": ">= 0.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": {
"node_modules/function-bind": {
"version": "1.1.2",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1906,13 +1771,6 @@
@@ -1906,13 +1771,6 @@
"node": ">= 0.4"
"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": {
"node_modules/global-agent": {
"version": "4.1.3",
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz",
@@ -2141,13 +1999,6 @@
@@ -2141,13 +1999,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
"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": {
"node_modules/ip-address": {
"version": "10.5.0",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
@@ -2544,19 +2395,6 @@
@@ -2544,19 +2395,6 @@
"node": ">= 0.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": {
"node_modules/minimist": {
"version": "1.2.8",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -2599,13 +2437,6 @@
@@ -2599,13 +2437,6 @@
"mkdirp": "bin/cmd.js"
"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": {
"node_modules/ms": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -2662,13 +2493,6 @@
@@ -2662,13 +2493,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"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": {
"node_modules/negotiator": {
"version": "0.6.3",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -2678,19 +2502,6 @@
@@ -2678,19 +2502,6 @@
"node": ">= 0.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": {
"node_modules/node-domexception": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -2809,16 +2620,6 @@
@@ -2809,16 +2620,6 @@
"node": ">= 0.8"
"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": {
"node_modules/onnxruntime-common": {
"version": "1.27.0",
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz",
@@ -3088,34 +2889,6 @@
@@ -3088,34 +2889,6 @@
"node": ">=0.10.0"
"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": {
"node_modules/process-nextick-args": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -3139,17 +2912,6 @@
@@ -3139,17 +2912,6 @@
"url": "https://opencollective.com/express"
"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": {
"node_modules/pvtsutils": {
"version": "1.3.6",
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
@@ -3208,22 +2970,6 @@
@@ -3208,22 +2970,6 @@
"node": ">= 0.8"
"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": {
"node_modules/readable-stream": {
"version": "2.3.8",
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -3511,53 +3257,6 @@
@@ -3511,53 +3257,6 @@
"url": "https://github.com/sponsors/ljharb"
"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": {
"node_modules/simple-swizzle": {
"version": "0.2.4",
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
@@ -3617,16 +3316,6 @@
@@ -3617,16 +3316,6 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
"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": {
"node_modules/supports-color": {
"version": "7.2.0",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -3655,58 +3344,6 @@
@@ -3655,58 +3344,6 @@
"node": ">=18"
"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": {
"node_modules/thunky": {
"version": "1.1.0",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
@@ -3755,19 +3392,6 @@
@@ -3755,19 +3392,6 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
"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": {
"node_modules/tweetnacl": {
"version": "1.0.3",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
@@ -3938,13 +3562,6 @@
@@ -3938,13 +3562,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
"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": {
"node_modules/ws": {
"version": "8.21.3",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
@@ -57,7 +57,6 @@
@@ -57,7 +57,6 @@
"node": ">=18.0.0"
"node": ">=18.0.0"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8",
- "better-sqlite3": "^11.3.0"
+ "@electric-sql/pglite": "^0.5.8"
}
}
}
}
@@ -58,7 +58,7 @@
@@ -58,7 +58,7 @@
<thead><tr><th>Layer</th><th>Tech</th><th>Notes</th></tr></thead>
<thead><tr><th>Layer</th><th>Tech</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
<tr><td>Backend</td><td>Node.js + Express</td><td>All routes return JSON</td></tr>
<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>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>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>
<tr><td>Chat/Control</td><td>WebSocket (ws)</td><td>Real-time messaging</td></tr>
deleted file mode 100644
deleted file mode 100644
@@ -1,116 +0,0 @@
@@ -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 };
@@ -16,7 +16,6 @@
@@ -16,7 +16,6 @@
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
const h = require('../test/n-1/harness');
const h = require('../test/n-1/harness');
const svc = require('../test/n-1/service');
const svc = require('../test/n-1/service');
@@ -27,10 +26,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -27,10 +26,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
const ref = process.argv[2] || 'HEAD';
const ref = process.argv[2] || 'HEAD';
const wt = h.worktree(ROOT, ref);
const wt = h.worktree(ROOT, ref);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-n-1-record-'));
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 dataDir = path.join(tmp, 'data');
- const sqlOut = path.join(tmp, 'sql.json');
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
let server = null;
let server = null;
try {
try {
@@ -43,8 +39,8 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -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)`);
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.
// 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 manifest = await (await fetch(`${server.url}/release.json`)).json();
const probe = await h.notFoundProbe(server.url);
const probe = await h.notFoundProbe(server.url);
const requests = h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false });
const requests = h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false });
@@ -98,41 +94,16 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
@@ -98,41 +94,16 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
await server.close();
await server.close();
server = null;
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 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 clients = svc.clientReleases ? svc.clientReleases() : null;
const migDir = path.join(wt.dir, 'migrations');
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.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, '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 {
} finally {
if (server) await server.close();
if (server) await server.close();
wt.remove();
wt.remove();
deleted file mode 100755
deleted file mode 100755
@@ -1,213 +0,0 @@
@@ -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}"
@@ -76,9 +76,8 @@ async function seedControlPresetsForUser(userId) {
@@ -76,9 +76,8 @@ async function seedControlPresetsForUser(userId) {
* OpenVibe.Live — Database Connection & Helpers
* 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
* 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
* (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 path = require('path');
const fs = require('fs');
const fs = require('fs');
@@ -1271,7 +1270,7 @@ async function countRecentlyOnlineStreamers() {
@@ -1271,7 +1270,7 @@ async function countRecentlyOnlineStreamers() {
// Public-facing site totals for the home hero stats bar.
// Public-facing site totals for the home hero stats bar.
let _homeStatsCache = null;
let _homeStatsCache = null;
let _homeStatsCacheAt = 0;
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) ──────────────
// ── Viewer trend sampling (home hero sparkline) ──────────────
// One row every ~5 minutes: total native viewers + live stream count.
// One row every ~5 minutes: total native viewers + live stream count.
@@ -57,12 +57,6 @@ async function snapshot(services = {}) {
@@ -57,12 +57,6 @@ async function snapshot(services = {}) {
jobs: await safe(() => require('./utils/jobs').snapshot(), []),
jobs: await safe(() => require('./utils/jobs').snapshot(), []),
workQueues: await safe(() => require('./utils/limit').snapshot(), []),
workQueues: await safe(() => require('./utils/limit').snapshot(), []),
migrations: await safe(async () => await require('./db/migrations').getStatus(db.getDb()), []),
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`) };
- }),
};
};
}
}
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* live.stream.started a stream row went live (subject: stream <id>, revision 1)
* 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)
* 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
* 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
* 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.
* the rows wait and are retried with backoff; going live never waits on either.
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
* news-service (this) ←→ sources (reddit, newsapi, rss, ...)
* news-service (this) ←→ sources (reddit, newsapi, rss, ...)
* → chat-delivery.js event() (Chat's ingress)
* → 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).
* Headline dedup via in-memory Set (cleared on restart, bounded to 500).
*/
*/
'use strict';
'use strict';
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
* Everything else is 404. Server routes (API, docs, overlays, popouts, legal pages, static files)
* Everything else is 404. Server routes (API, docs, overlays, popouts, legal pages, static files)
* answer before the fallback and are not affected.
* 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
* 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
* 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.
* answer in time, the page answers 200: an outage upstream must never turn real pages into 404s.
@@ -332,7 +332,7 @@ sudo apt autoremove -y</code></pre>
@@ -332,7 +332,7 @@ sudo apt autoremove -y</code></pre>
<h3>Install baseline packages</h3>
<h3>Install baseline packages</h3>
<pre><code class="language-bash">sudo apt install -y \
<pre><code class="language-bash">sudo apt install -y \
git curl wget unzip ca-certificates gnupg \
git curl wget unzip ca-certificates gnupg \
- nginx ffmpeg ufw fail2ban sqlite3 \
+ nginx ffmpeg ufw fail2ban \
build-essential python3 python3-pip jq</code></pre>
build-essential python3 python3-pip jq</code></pre>
<h3>Optional: create a dedicated app user</h3>
<h3>Optional: create a dedicated app user</h3>
@@ -412,7 +412,8 @@ PORT=3000
@@ -412,7 +412,8 @@ PORT=3000
BASE_URL=https://your-domain.example
BASE_URL=https://your-domain.example
JWT_SECRET=replace-with-a-long-random-secret
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
RTMP_PORT=1935
JSMPEG_VIDEO_PORT=9710
JSMPEG_VIDEO_PORT=9710
@@ -762,9 +763,8 @@ sudo systemctl status openvibelive</code></pre>
@@ -762,9 +763,8 @@ sudo systemctl status openvibelive</code></pre>
<section id="close-reg" class="fade-in">
<section id="close-reg" class="fade-in">
<h2><i class="fa-solid fa-user-lock"></i> 21. Close registration after bootstrap</h2>
<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>
<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>
</section>
<!-- ═══════════════ 22. fail2ban ═══════════════ -->
<!-- ═══════════════ 22. fail2ban ═══════════════ -->
@@ -911,7 +911,7 @@ sudo ufw status verbose</code></pre>
@@ -911,7 +911,7 @@ sudo ufw status verbose</code></pre>
<h3>Best overall pick — B3-32</h3>
<h3>Best overall pick — B3-32</h3>
<ul>
<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>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>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>
<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>
@@ -961,7 +961,7 @@ sudo ufw status verbose</code></pre>
</ul>
</ul>
<h3>Storage dominates once VODs are enabled</h3>
<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>
</section>
<!-- ═══════════════ OVH Bandwidth ═══════════════ -->
<!-- ═══════════════ OVH Bandwidth ═══════════════ -->
deleted file mode 100644
deleted file mode 100644
@@ -1,240 +0,0 @@
@@ -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); });
@@ -1,14 +1,5 @@
@@ -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
* node test/deploy-wrapper.test.js
*/
*/
'use strict';
'use strict';
@@ -33,12 +24,9 @@ fi
@@ -33,12 +24,9 @@ fi
echo "ovhost $*" >> "${log}"
echo "ovhost $*" >> "${log}"
exit "\${FAKE_EXIT:-0}"
exit "\${FAKE_EXIT:-0}"
`, { mode: 0o755 });
`, { 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 = {}) => {
const run = (args = [], env = {}) => {
fs.rmSync(log, { force: true });
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 = [];
let calls = [];
try { calls = fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean); } catch { /* none */ }
try { calls = fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean); } catch { /* none */ }
return { code: r.status, out: r.stdout + r.stderr, calls };
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
@@ -69,7 +57,7 @@ check('--force is refused on the ovhost path (it would drop live streams), never
const r = run(['--force']);
const r = run(['--force']);
assert.strictEqual(r.code, 1, r.out);
assert.strictEqual(r.code, 1, r.out);
assert.deepStrictEqual(r.calls, []);
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', () => {
check('an unknown flag is a usage error', () => {
@@ -78,37 +66,33 @@ check('an unknown flag is a usage error', () => {
@@ -78,37 +66,33 @@ check('an unknown flag is a usage error', () => {
assert.deepStrictEqual(r.calls, []);
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') });
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');
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.match(wrapper, /^set -euo pipefail$/m);
+ assert.doesNotMatch(wrapper, /LEGACY|exec bash/);
});
});
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -11,11 +11,8 @@
@@ -11,11 +11,8 @@
* for a 2xx, the same status otherwise (a 5xx only needs the route), JSON where there was JSON,
* 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
* 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.
* 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
* 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.
* scripts/n-1-record.js). This test needs no git history and no network.
@@ -26,7 +23,6 @@ const assert = require('assert');
@@ -26,7 +23,6 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
const compat = require('openvibe-shared/release-compat');
const compat = require('openvibe-shared/release-compat');
const h = require('./n-1/harness');
const h = require('./n-1/harness');
const svc = require('./n-1/service');
const svc = require('./n-1/service');
@@ -43,7 +39,7 @@ async function check(name, fn) {
@@ -43,7 +39,7 @@ async function check(name, fn) {
(async () => {
(async () => {
const age = Math.round((Date.now() - Date.parse(client.recorded_at)) / 86400000);
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');
console.log('n-1: the harness');
await check('finds call sites, their method and template, and skips computed paths', () => {
await check('finds call sites, their method and template, and skips computed paths', () => {
@@ -63,53 +59,20 @@ async function check(name, fn) {
@@ -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.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));
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 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');
const dataDir = path.join(tmp, 'data');
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.mkdirSync(dataDir, { 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;
let server = null;
try {
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();
const manifest = await (await fetch(`${server.url}/release.json`)).json();
console.log('n-1: the N-1 client against this server');
console.log('n-1: the N-1 client against this server');
@@ -140,17 +103,6 @@ async function check(name, fn) {
@@ -140,17 +103,6 @@ async function check(name, fn) {
});
});
await server.close();
await server.close();
server = null;
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 {
} finally {
if (server) { console.log(server.log().slice(-1500)); await server.close(); }
if (server) { console.log(server.log().slice(-1500)); await server.close(); }
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -1,15 +1,15 @@
@@ -1,15 +1,15 @@
'use strict';
'use strict';
/**
/**
* N-1 harness (roadmap WS-P task 11, ADR-016's mixed-version window): the previous release's client
* 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
* 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
* 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
* is N-1 for the next deploy) into a temporary worktree, boots it, and
* writes test/fixtures/n-1/{client,worker}.json
* 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(...)`
* Client calls are found statically in the N-1 client code (the call sites of `api(...)`, `fetch(...)`
* and the service's other wrappers, with a literal or template path), made concrete with sample values,
* and the service's other wrappers, with a literal or template path), made concrete with sample values,
@@ -18,8 +18,6 @@
@@ -18,8 +18,6 @@
* and the response fields it reads (the JSON key paths whose every key the N-1 client code names, with
* and the response fields it reads (the JSON key paths whose every key the N-1 client code names, with
* their types).
* 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 fs = require('fs');
const path = require('path');
const path = require('path');
@@ -668,87 +666,6 @@ function wsProblems(recorded, received) {
@@ -668,87 +666,6 @@ function wsProblems(recorded, received) {
return problems;
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 }. */
/** Every file under dir with one of the extensions, as { name (relative to root), text }. */
function readTree(root, dirs, exts = ['.js']) {
function readTree(root, dirs, exts = ['.js']) {
const out = [];
const out = [];
@@ -768,50 +685,6 @@ function readTree(root, dirs, exts = ['.js']) {
@@ -768,50 +685,6 @@ function readTree(root, dirs, exts = ['.js']) {
return out.sort((a, b) => (a.name < b.name ? -1 : 1));
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 ──────────────────────────────────────────────────────
// ── Git ──────────────────────────────────────────────────────
/** A detached checkout of `ref` sharing root's node_modules → { dir, sha, remove() }. test/helpers/old-release.js. */
/** A detached checkout of `ref` sharing root's node_modules → { dir, sha, remove() }. test/helpers/old-release.js. */
@@ -839,6 +712,6 @@ module.exports = {
@@ -839,6 +712,6 @@ module.exports = {
flatten, readsOf, readProblems, statusCompatible,
flatten, readsOf, readProblems, statusCompatible,
send, requestsFor, notFoundProbe, isNoRoute, callProblems,
send, requestsFor, notFoundProbe, isNoRoute, callProblems,
wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
- stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
+ readTree,
worktree, gitFiles, summarize,
worktree, gitFiles, summarize,
};
};
@@ -1,28 +1,6 @@
@@ -1,28 +1,6 @@
'use strict';
'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 path = require('path');
-
const cwd = process.cwd();
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'));
const drill = require(path.join(cwd, 'server', 'drill'));
drill.readOnly = (req, res, next) => next();
drill.readOnly = (req, res, next) => next();
@@ -37,25 +37,7 @@ function baseEnv(extra) {
@@ -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 };
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 = `
const SEED_PG = `
console.log = () => {}; console.warn = () => {};
console.log = () => {}; console.warn = () => {};
const db = require('./server/db/database');
const db = require('./server/db/database');
@@ -108,24 +90,21 @@ module.exports = {
@@ -108,24 +90,21 @@ module.exports = {
[/^q$|query|search|term/i, 'n1'],
[/^q$|query|search|term/i, 'n1'],
],
],
- sqlDirs: ['server'],
- ledgerTables: ['schema_migrations'],
-
/** Seeds a database with the release in `dir` (its initDb migrates first). */
/** 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)}`);
if (r.status !== 0) throw new Error(`seeding failed:\n${String(r.stderr || '').slice(-2000)}`);
},
},
/** Boots the release in `dir` → { url, headers(auth), close() }. */
/** 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' } });
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.mkdirSync(path.join(dataDir, 'keys'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'keys', 'openvibe-tools-public.pem'), keys.publicKey);
fs.writeFileSync(path.join(dataDir, 'keys', 'openvibe-tools-public.pem'), keys.publicKey);
const port = await freePort();
const port = await freePort();
const child = spawn(process.execPath, ['-r', PRELOAD, 'server/index.js'], {
const child = spawn(process.execPath, ['-r', PRELOAD, 'server/index.js'], {
cwd: dir,
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'],
stdio: ['ignore', 'pipe', 'pipe'],
});
});
let log = '';
let log = '';
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
* (200 "degraded", naming what is missing)
* (200 "degraded", naming what is missing)
* - /release.json validates as registry.release-manifest@1 and names /release-metrics, whose beacons
* - /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
* (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
* Run: node test/observability.test.js
*/
*/
@@ -43,17 +43,10 @@ assert.strictEqual(load({ NODE_ENV: 'production', LISTEN_HOST: '10.0.0.5', BASE_
@@ -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: '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');
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');
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');
console.log('systemd units: all checks passed');