openvibers/openvibe.network Commits

Network: the migration era is deleted (SQLite tooling, follows import, legacy deploy path, better-sqlite3, DB_PATH) (#116)

OP OpenVibers committed 10 Oct 2026 · d65d5951dd · 43 files, +56 −3477

Plan T1 "Delete the migration era" (and T2's one-time scripts). Network has run on PostgreSQL (ov_network) since 2026-10-02. PGlite stays for development and tests.

**Deleted:**
- the SQLite → PostgreSQL tooling: `scripts/migrate-to-postgres.js`, `pg-row-parity.js`, `rehearse-pg-cutover.js`, `pg-preflight-collisions.js` and their tests;
- the one-time follows import and preflight (`scripts/follows-import.js`, `follows-preflight.js`, `importFollows()` in `server/identity/follows.js`);
- the T2 cutover runbooks and evidence;
- `deploy/scripts/deploy-legacy.sh` and the wrapper's fallback to it. `deploy.sh` now calls `ovhost deploy|rollback|plan network` directly, and a missing ovhost exits 1;
- `better-sqlite3`, and every `DB_PATH` mention (`.env.example`, config, docs).

**Updated:** comments that still described SQLite or better-sqlite3 now say PostgreSQL. README and docs no longer link the deleted files. `test/deploy-wrapper.test.js` asserts the direct ovhost calls and that no legacy path remains. `follow-notify.test.js` checks follows directly instead of through the deleted importer. The lockfile is regenerated.

Production without `DATABASE_URL` already fails fast (checked); the deploy env gate requires it too. `ov test`: 66 passed, 13 unchanged since green. Drafted by a Codex job and reviewed here; the loopback HTTP tests were run here.

Changes

ddiff --git a/.env.example b/.env.example
iindex b52b520d..269b654b 100644
---- a/.env.example
++++ b/.env.example
@@@ -59,7 +59,7 @@ OWNER_USERNAME=
# PostgreSQL (ADR-035, plan T2), written by OpenVibe.Host roles/data/add-service.sh network: the runtime
# role through PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters).
# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite (PGLITE_DIR overrides it);
--# production refuses to boot. DB_PATH is read only by the one-time import scripts, never by serving code.
++# production refuses to boot.
DATABASE_URL=
DATABASE_DIRECT_URL=
VALKEY_URL=
ddiff --git a/README.md b/README.md
iindex cd4f9514..09dcce80 100644
---- a/README.md
++++ b/README.md
@@@ -97,7 +97,7 @@ openvibe.network (port 4000)
│ └── my.html # Account management (profile, sessions, notifications)
├── deploy/
│ ├── nginx/ # openvibe.network.conf; network-subdomains.conf (status/themes/admin/auth/api. redirects); parked-domains.conf
--│ ├── scripts/ # deploy.sh (runs `ovhost deploy network`), deploy-legacy.sh (its fallback)
++│ ├── scripts/ # deploy.sh (runs `ovhost deploy network`)
│ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env)
└── .env.example
```
@@@ -148,8 +148,7 @@ The script requires the database named `ov_network` (or an explicit `--database-
anything restarts, `deploy/systemd/openvibe-network.service` installed when it differs (daemon-reload), the
restart, `/api/ready` polled, an automatic rollback if it does not come up, and the release announced.
`--restart`, `--wait-idle`, `--force` and `--rollback` (`ovhost rollback network`) are passed on; `DRY_RUN=1`
--prints `ovhost plan network`. When ovhost is missing, too old or does not manage Network, the wrapper runs
--`deploy/scripts/deploy-legacy.sh`, the previous script, unchanged (`OVHOST_LEGACY=1` forces it).
++prints `ovhost plan network`. If ovhost is missing, the wrapper exits with an error.
The unit is `openvibe-network.service` (runs as `ubuntu` from `/opt/openvibe.network`) on
`127.0.0.1:4000`, the env file `/etc/openvibe/network.env`. Rollback: automatic when `/api/ready` does not
@@@ -501,25 +500,6 @@ A target is a Live channel today (`channel`, named by its owner's subject), and
buttons use it.
- **Go-live source:** go-live notifications read the followers here, and only here (plan T2); there is no
switch back to Live.
--- **Backfill from Live:** follows made on Live while Live's own `FOLLOWS_AUTHORITY` was unset never reached
-- Network. `npm run follows-import -- --live-db /opt/openvibe.live/data/live.db` reads Live's `follows` and
-- `linked_accounts` read-only and prints what it would import (dry run); add `--apply` to import in one
-- transaction (no events, no notifications). A pair whose side has no subject is held in
-- `follow_import_holds`; a pair Network already has, followed or unfollowed, is left alone. Safe to re-run;
-- a later run imports newly mapped pairs and clears their holds. Both scripts use `DATABASE_URL` and never
-- migrate or seed.
--- **Cutover order:** Keep Live's direct `POST /internal/events/stream-live` enabled and leave
-- `NETWORK_GO_LIVE_FOLLOWS_READY` unset. Pause Live follow writes, then run
-- `npm run follows-import -- --live-db /opt/openvibe.live/data/live.db --reconcile` to preview the
-- final Live snapshot and rerun with `--reconcile --apply`. Reconciliation deactivates Live-sourced
-- active Network pairs missing from the snapshot, without emitting follow events or notifications;
-- it preserves Network-sourced pairs and earlier Network unfollows. Check `npm run follows-preflight`
-- and resolve holds before proceeding. Set Live's `FOLLOWS_AUTHORITY=network` and restart Live while
-- writes are paused; verify a follow and unfollow reach Network, then resume Live writes. Only then
-- set `NETWORK_GO_LIVE_FOLLOWS_READY=1` in Network's environment and restart Network. Keep Live's
-- direct call enabled until Network's `live.stream.started` outcome logs show delivery.
--- **Preflight:** `npm run follows-preflight` prints, read-only and counts only, the active follows (with
-- their channels and followers), the unfollowed rows and the unresolved `follow_import_holds` by reason.
## Multi-Account Switching
@@@ -646,8 +626,7 @@ holds only Network's wiring: its path options (`paramPrefixes`, `pathRules`) and
`analytics-prune` job: first run 5 minutes after boot, then every 24 h). Rollups are kept.
- **Operator CLI:** `scripts/analytics-prune.js` (over `openvibe-shared/analytics/pg`'s
`pruneRawEventsPg`) runs a dry run by default and changes nothing; `--apply` deletes raw events older
-- than `--days` (1..30, default 30). Run it where Network runs, with `DATABASE_URL` set. The SQLite
-- prune CLI (online backup, VACUUM, one-time scrub) is gone with the file.
++ than `--days` (1..30, default 30). Run it where Network runs, with `DATABASE_URL` set.
## Shared Client Libraries
@@@ -733,7 +712,6 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
| `network_event_outbox` | Network's events on their way to OpenVibe.Events (developer projects, user modules, blocks) |
| `user_blocks` | Platform blocks by subject (blocker, blocked, active, per-pair revision) |
| `user_follows` | The follow graph by subject (follower, target type and id, active, notify flags, per-pair revision, source) |
--| `follow_import_holds` | Imported follows held because a side had no subject (ADR-030 step 2) |
| `analytics_visitor_days`, `analytics_day_salts` | The current day's salted visitor hashes and salt, deleted after the day's final rollup |
---
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
ddeleted file mode 100755
iindex 8e066919..00000000
---- a/deploy/scripts/deploy-legacy.sh
++++ /dev/null
@@@ -1,105 +0,0 @@
--#!/usr/bin/env bash
--# ═══════════════════════════════════════════════════════════════════════
--# OpenVibe.Network — Deploy Script
--# Pulls latest from GitHub, updates the systemd service unit, and restarts the service.
--#
--# Usage (from the server):
--# sudo /opt/openvibe.network/deploy/scripts/deploy.sh
--#
--# Or remotely:
--# ssh openvibe.network "sudo /opt/openvibe.network/deploy/scripts/deploy.sh"
--# ═══════════════════════════════════════════════════════════════════════
--set -euo pipefail
--
--REPO_DIR="${REPO_DIR:-/opt/openvibe.network}"
--SERVICE="${SERVICE:-openvibe-network}"
--SERVICE_UNIT_SOURCE="${SERVICE_UNIT_SOURCE:-$REPO_DIR/deploy/systemd/${SERVICE}.service}"
--SERVICE_UNIT_DEST="${SERVICE_UNIT_DEST:-/etc/systemd/system/${SERVICE}.service}"
--SITE_URL="${SITE_URL:-https://openvibe.network}"
--API_URL="${API_URL:-http://127.0.0.1:4000}"
--GIT_REMOTE="${GIT_REMOTE:-origin}"
--GIT_BRANCH="${GIT_BRANCH:-main}"
--HEALTH_PATH="${HEALTH_PATH:-/api/health}"
--DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-15}"
--
--cd "$REPO_DIR"
--
--echo "╔══════════════════════════════════════╗"
--echo "║ OpenVibe.Network Deploy Script ║"
--echo "╚══════════════════════════════════════╝"
--echo ""
--
--# 1. Record current commit before pull
--OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo "none")
--echo "[Deploy] Current commit: ${OLD_HASH:0:8}"
--
--# 2. Pull latest from GitHub
--echo "[Deploy] Pulling from ${GIT_REMOTE} ${GIT_BRANCH}..."
--git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-only
--NEW_HASH=$(git rev-parse HEAD)
--echo "[Deploy] New commit: ${NEW_HASH:0:8}"
--
--# 2b. Install dependencies when the lockfile changed (e.g. a new openvibe-contracts release).
--if [ "$OLD_HASH" = "none" ] || ! git diff --quiet "$OLD_HASH" "$NEW_HASH" -- package-lock.json; then
-- # install (not ci): updates node_modules in place instead of deleting it under the running service
-- echo "[Deploy] package-lock.json changed: npm install --omit=dev"
-- npm install --omit=dev --no-audit --no-fund
--fi
--
--# 3. Update systemd service config if the repo includes one
--UNIT_UPDATED=false
--if [ -f "$SERVICE_UNIT_SOURCE" ]; then
-- echo "[Deploy] Found service unit source: ${SERVICE_UNIT_SOURCE}"
-- if [ ! -f "$SERVICE_UNIT_DEST" ] || ! cmp -s "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"; then
-- echo "[Deploy] Installing updated service unit to ${SERVICE_UNIT_DEST}"
-- sudo cp "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"
-- sudo chmod 644 "$SERVICE_UNIT_DEST"
-- UNIT_UPDATED=true
-- else
-- echo "[Deploy] Service unit is already up to date."
-- fi
-- if [ "$UNIT_UPDATED" = true ]; then
-- echo "[Deploy] Reloading systemd daemon..."
-- sudo systemctl daemon-reload
-- sudo systemctl enable "$SERVICE" >/dev/null 2>&1 || true
-- fi
--else
-- echo "[Deploy] No service unit file found at ${SERVICE_UNIT_SOURCE}, skipping systemd update."
--fi
--
--# 4. Check if there are actually new commits
--if [ "$OLD_HASH" = "$NEW_HASH" ]; then
-- echo "[Deploy] Already up to date — no new commits."
-- echo "[Deploy] Restarting service anyway..."
-- sudo systemctl restart "$SERVICE"
-- echo "[Deploy] Done. ✅"
-- exit 0
--fi
--
--# 5. Get commit log between old and new
--COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${NEW_HASH}" 2>/dev/null || echo "Update deployed")
--COMMIT_COUNT=$(echo "$COMMIT_LOG" | wc -l | tr -d ' ')
--echo "[Deploy] ${COMMIT_COUNT} new commit(s):"
--echo "$COMMIT_LOG"
--echo ""
--
--# 6. Restart the service
--echo "[Deploy] Restarting ${SERVICE}..."
--sudo systemctl restart "$SERVICE"
--
--# 7. Wait for service to come back up
--if [ "$COMMIT_COUNT" -gt 0 ]; then
-- echo -n "[Deploy] Waiting for server..."
-- for i in $(seq 1 "${DEPLOY_TIMEOUT}"); do
-- sleep 1
-- if curl -sf "${API_URL}${HEALTH_PATH}" > /dev/null 2>&1; then
-- echo " up! ✅"
-- break
-- fi
-- echo -n "."
-- done
--fi
--
--echo ""
--echo "[Deploy] Deployment complete! 🎉"
--echo "[Deploy] ${COMMIT_COUNT} commit(s) deployed: ${OLD_HASH:0:8} → ${NEW_HASH:0:8}"
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex 5e36e86f..70147315 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -1,29 +1,19 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════════
# OpenVibe.Network — deploy: a thin wrapper around `ovhost deploy network --install-units`
--# (OpenVibe.Host, strategy git-checkout; roadmap WS-N task 11; OpenVibe.Host docs/deploy-strategies.md).
++# (OpenVibe.Host, strategy git-checkout).
#
# sudo /opt/openvibe.network/deploy/scripts/deploy.sh ovhost deploy network --install-units
--# sudo /opt/openvibe.network/deploy/scripts/deploy.sh --restart … --restart (the old script restarted even
--# with nothing new; ovhost does only when asked)
++# sudo /opt/openvibe.network/deploy/scripts/deploy.sh --restart … --restart
# sudo /opt/openvibe.network/deploy/scripts/deploy.sh --rollback ovhost rollback network
# DRY_RUN=1 /opt/openvibe.network/deploy/scripts/deploy.sh ovhost plan network
#
--# ovhost does what this script did, as the checkout owner: pull (fast-forward only), npm install --omit=dev when
--# the lockfile or dependency fields changed (then the lockfile is restored), deploy/systemd/openvibe-network.service
--# installed when it differs (daemon-reload), restart, /api/ready polled. New: every dependency must resolve before
--# the restart, a release that does not come up is rolled back (exit 3), every attempt is in `ovhost releases
--# network`, freezes are honoured (exit 6) and the release is announced.
--#
--# Fallback: deploy-legacy.sh (the previous script, unchanged) when ovhost is missing or too old (no
--# `capabilities`, deploy-api < 1), or the host inventory does not manage network; OVHOST_LEGACY=1 forces it.
++# ovhost deploys as the checkout owner, installs the unit, restarts, checks /api/ready and
++# rolls back a release that does not come up.
# ═══════════════════════════════════════════════════════════════════════
set -euo pipefail
SERVICE=network
--STRATEGY=git-checkout
--HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
--LEGACY="${DEPLOY_LEGACY:-$HERE/deploy-legacy.sh}"
OVHOST="${OVHOST:-/usr/local/bin/ovhost}"
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
@@@ -38,27 +28,10 @@ while [ "$#" -gt 0 ]; do
esac
done
--legacy() {
-- echo "[Deploy] $1 — running deploy-legacy.sh (the previous deploy script) instead"
-- if [ "$CMD" = rollback ] || [ "${DRY_RUN:-0}" = 1 ]; then echo "[Deploy] ✗ deploy-legacy.sh has no --rollback or DRY_RUN; nothing was done" >&2; exit 1; fi
-- exec bash "$LEGACY"
--}
--
--REASON=""
--probe() {
-- if [ "${OVHOST_LEGACY:-0}" = 1 ]; then REASON="OVHOST_LEGACY=1"; return 1; fi
-- if ! command -v "$OVHOST" >/dev/null 2>&1; then REASON="ovhost not found ($OVHOST)"; return 1; fi
-- local caps api
-- if ! caps=$("${SUDO[@]}" "$OVHOST" capabilities "$SERVICE" 2>/dev/null); then REASON="this ovhost has no 'capabilities' (too old) or no inventory entry for $SERVICE"; return 1; fi
-- api=$(printf '%s\n' "$caps" | sed -n 's/^deploy-api=//p')
-- case "$api" in ''|*[!0-9]*) REASON="this ovhost reports no deploy-api (too old)"; return 1 ;; esac
-- if [ "$api" -lt 1 ]; then REASON="this ovhost's deploy-api is $api, 1 is needed"; return 1; fi
-- if ! printf '%s\n' "$caps" | grep -qx "strategy=$STRATEGY"; then REASON="the host inventory does not deploy $SERVICE with strategy $STRATEGY ($(printf '%s\n' "$caps" | sed -n 's/^strategy=//p'))"; return 1; fi
-- if ! printf '%s\n' "$caps" | grep -qx "managed=yes"; then REASON="ovhost does not manage $SERVICE"; return 1; fi
-- return 0
--}
--
--probe || legacy "$REASON"
++if ! command -v "$OVHOST" >/dev/null 2>&1; then
++ echo "[Deploy] ovhost not found ($OVHOST); install ovhost before deploying network" >&2
++ exit 1
++fi
if [ "${DRY_RUN:-0}" = 1 ]; then exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE"; fi
[ "$CMD" = deploy ] && FLAGS+=(--install-units)
ddiff --git a/docs/cutover-evidence-t2.md b/docs/cutover-evidence-t2.md
ddeleted file mode 100644
iindex 18cf98fa..00000000
---- a/docs/cutover-evidence-t2.md
++++ /dev/null
@@@ -1,137 +0,0 @@
--# T2 cutover: closeout evidence
--
--What the read-only interfaces can actually prove about the OpenVibe.Network PostgreSQL cutover (plan T2,
--ADR-035; procedure in [cutover-t2-postgres.md](cutover-t2-postgres.md)). Checked **2026-10-03 (UTC, host
--clock)** through `ov access run openvibe-ovh <action>` — `health`, `service-status`, `releases`, `plan`,
--`validate`, `env-names`, `env-set`, `show`, `journal`, `certs`. Every result below is a literal output of one
--of those actions or of a read-only Git query on this checkout.
--
--Nothing here was deployed, restarted, rolled back or imported; no credential, URL or env **value** was read or
--printed; no check that needs one was guessed. Checks that need an interface the broker does not expose are
--marked **pending** in their own section.
--
--SHAs are printed as `ovhost` reports them (12 hex characters).
--
--## Release and readiness — verified
--
--- `health network` (exit 0): `network bad7b106fda0 ready openvibe-network=active`. `ovhost` needs its
-- readiness probe (the service's `/api/ready`) to answer before it calls a service `ready`.
--- `service-status network` (exit 0): the same line.
--- `plan network` (exit 0): `network: bad7b106fda0 → bad7b106fda0 (up to date) on main`; strategy
-- `git-checkout`, `0` files changed, `restart no`.
--- `validate network` (exit 0): `ok checkout /opt/openvibe.network owned by ubuntu`; `ok checkout clean at
-- bad7b106fda0`; `ok units openvibe-network.service active/running from
-- /etc/systemd/system/openvibe-network.service`; `ok port 4000 held by node(734262)`; `ok nginx
-- /etc/nginx/sites-enabled/openvibe.network.conf enabled`; `ok nginx nginx -t clean`; `ok deps every
-- dependency resolves`; lifecycle `network` manifest in openvibe-contracts v0.83.0, checkout
-- openvibe-contracts 0.85.0; `network: valid (0 error(s), 0 warning(s))`.
--
--`bad7b106fda0` is origin/main HEAD (`git ls-remote origin` → `bad7b106fda07771109fdd41295ae481aa373dfb HEAD`).
--
--**Why this is a PostgreSQL-only release.** `bad7b106fda0` is a descendant of `206ab36`, the commit that made
--production refuse a SQLite path. At that commit `server/db/database.js` throws when `NODE_ENV=production` and
--`DATABASE_URL` is unset, and `server/config.js` states there is no SQLite file. Locally, `git diff --name-only
--4e7b56b bad7b10` (this checkout is one commit behind) touches 26 files and none of `server/db/`,
--`server/config.js` or `package.json`, so the running release has that same boot rule. A release that is
--`active` and `ready` therefore booted with `DATABASE_URL` set and serves from PostgreSQL.
--
--**Boot of the running release** (`journal openvibe-network.service`, 2026-10-03T04:37:53–04:37:59Z): the old
--process stops in 5 ms, systemd starts `env[734262]`, then `[DB] Synced 35 built-in themes`,
--`[Identity] subject ids: 0 users, 0 guests assigned; 3 legacy map rows seeded`, `[DB] Central database
--seeded`, `[Auth] RS256 keypair loaded`, `[Email] Resend initialized`, `[Events consumer] on:
--POST /internal/events`, `[push] VAPID initialized`, the banner on port 4000. There is no
--`DATABASE_URL unset: embedded PGlite database` warning (the development fallback). The `journal` action clips
--its answer to a recent window (the call returned about 90 lines, 02:54–04:39Z), so older boots are not
--readable.
--
--### Release history, as observed (`releases network`, from 2026-10-01)
--
--```
--2026-10-01T18:56:31.170Z deploy 20dd7ff02e0c → 5781a6b9daf5 failed-rolled-back ovbroker lockfile
--2026-10-01T18:57:19.036Z rollback 20dd7ff02e0c → b5d870737f33 rolled-back ovbroker
--2026-10-01T19:03:06.415Z rollback b5d870737f33 → 20dd7ff02e0c rolled-back ubuntu
--2026-10-02T01:10:14.248Z deploy 20dd7ff02e0c → 427e196f6276 failed-rolled-back ovbroker lockfile
--2026-10-02T14:49:54.343Z deploy 20dd7ff02e0c → 2c6b8b5eae59 failed-rolled-back ovbroker lockfile
--2026-10-02T16:56:32.756Z deploy 20dd7ff02e0c → 757dc98b1b67 deployed ubuntu lockfile
--2026-10-02T17:19:12.262Z deploy 757dc98b1b67 → cbaacc48bd07 deployed ovbroker
--2026-10-03T00:54:33.837Z deploy cbaacc48bd07 → 4e7b56b2046f deployed ovbroker
--2026-10-03T04:37:53.267Z deploy 4e7b56b2046f → bad7b106fda0 deployed ovbroker
--```
--
--`20dd7ff` was deployed on 2026-09-29T14:31:31Z (the runbook's "last SQLite release"). All five later SHAs are
--descendants of the PostgreSQL-only `206ab36`. Three attempts ended `failed-rolled-back` (the first ran a
--rollback to `b5d870737f33`, then a second rollback returned production to `20dd7ff`); the first release that
--stayed up is `757dc98b1b67`. `ovhost` records no failure reason, so the runbook's unconfirmed
--missing-`DATABASE_URL` explanation stays unconfirmed.
--
--## Env presence and mode — verified
--
--- `validate network`: `ok env /etc/openvibe/network.env mode 600`; `ok env 4 required name(s) present and
-- non-empty`.
--- `env-set network` (names only): file `/etc/openvibe/network.env`, `found: true`, `empty: []`. The set
-- includes **`DATABASE_URL`**, **`DATABASE_DIRECT_URL`**, **`VALKEY_URL`** and `VALKEY_PREFIX`, alongside
-- `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `NETWORK_EVENTS_SECRET`,
-- `RESEND_API_KEY`, `VAPID_PRIVATE_KEY`, `DB_PATH`, `FOLLOWS_AUTHORITY` and the port/host/base-URL names.
-- `fromUnits`: `NODE_ENV`, `PATH`.
--- `env-names network`: `/opt/openvibe.network/.env.example` declares 30 names, `DATABASE_URL`,
-- `DATABASE_DIRECT_URL`, `VALKEY_URL` and `DATA_DIR` among them.
--
--`DB_PATH` and `DATA_DIR` remain declared/set but are the legacy names the PostgreSQL release no longer reads
--for serving (`server/config.js`).
--
--## Row parity — pending
--
--The step-7 parity result for the production import cannot be established from the interfaces available.
--`scripts/pg-row-parity.js` needs the SQLite source (`$COPY`) and `DATABASE_DIRECT_URL` (or `DATABASE_URL`);
--no broker action exposes either, and none runs a query or lists a file. **Not checked.**
--
--What does exist is the rehearsal: `test/pg-cutover-rehearsal.test.js` runs the whole sequence
--(pg-preflight → `migrate-to-postgres.js` → row-count parity → server boot with `/api/ready` green) on a seeded
--`20dd7ff` fixture, on PGlite by default and on a scratch database under `npm run test:pg`. That proves the
--tools, not production's rows.
--
--## Valkey ACL scope — pending
--
--Only presence is provable: `VALKEY_URL` and `VALKEY_PREFIX` are set and non-empty (above). The scope of the
--Valkey user — which commands it holds and that it is confined to the service's key prefix — needs a
--connection to Valkey, which no read-only action opens. **Not checked.**
--
--## Rollback copies — pending
--
--The runbook keeps `$COPY` (`/var/backups/openvibe/network-pre-t2-<T0>.db`, `chmod 0400`) and the live
--`/opt/openvibe.network/data/network.db` untouched for 30 days as the rollback and audit trail. No read-only
--action lists or stats those paths: `show network` returns only the inventory entry
--(`network /opt/openvibe.network units: openvibe-network.service`) and `validate network` does not cover
--backups. **Not checked.**
--
--The nearest interface, `ov access run openvibe-ovh db-backup network`, is a medium (write) action that would
--take a *new* backup; it cannot prove the pre-T2 copy or its mode, so it was not run. Note also that, with the
--cutover done, the previous release is PostgreSQL-only: a rollback is a code rollback and does not by itself
--return serving to SQLite (see the runbook's [Rollback](cutover-t2-postgres.md#rollback) note).
--
--## Unresolved, owner-only
--
--- **Cause of the three rollbacks.** `releases` carries no reason and the journal is clipped to recent boots,
-- so this stays unconfirmed as the runbook says.
--- **Whether steps 3–7 ran**, and with what values for `$T0`, `$T1`, the `cleaned:` count and the parity
-- output: not observable from any read-only action.
--- **Functional checks** (runbook step 9.3–9.6: sign-in with a pre-cutover account, OAuth authorize on a
-- first-party site, notifications, the OpenCoins balance against `$COPY`): need an account and a browser and
-- were not run.
--
--## Reproduce
--
--```bash
--ov access run openvibe-ovh health network
--ov access run openvibe-ovh service-status network
--ov access run openvibe-ovh plan network
--ov access run openvibe-ovh releases network
--ov access run openvibe-ovh validate network
--ov access run openvibe-ovh env-set network
--ov access run openvibe-ovh env-names network
--ov access run openvibe-ovh show network
--ov access run openvibe-ovh journal openvibe-network.service 200
--```
--
--Every one of these is a low-risk read. `health`, `releases` and `validate` are granted to `rung:*`;
--`deploy`, `rollback`, `restart` and `db-backup` are not, and were not used.
ddiff --git a/docs/cutover-pr-openvibe-network-34.md b/docs/cutover-pr-openvibe-network-34.md
iindex bb10abc1..de6aea2b 100644
---- a/docs/cutover-pr-openvibe-network-34.md
++++ b/docs/cutover-pr-openvibe-network-34.md
@@@ -6,8 +6,7 @@ grants. The data change is one new table, `dev_agent_grants`, and two indexes on
touched. The migration is `phase: expand` and every statement is `IF NOT EXISTS`, so it is safe to run on a
live production database and safe to run twice.
--This runbook covers the deploy of that migration. It is much smaller than the SQLite → PostgreSQL cutover
--([cutover-t2-postgres.md](cutover-t2-postgres.md)): there is no data move, no freeze window, no snapshot
++This runbook covers the deploy of that migration. There is no data move, freeze window or snapshot
import. The migration runs as part of the normal boot of the new release.
---
@@@ -155,4 +154,3 @@ In either case, restart the service after the rollback and confirm `health netwo
- **Data move.** There is none. The table starts empty.
- **Freeze window.** There is none. The migration is a fast `CREATE TABLE` + two `CREATE INDEX` on an empty
table; it takes milliseconds.
--- **SQLite path.** Production is PostgreSQL-only; the SQLite runner is not exercised here.
ddiff --git a/docs/cutover-t14-user-owned-trust.md b/docs/cutover-t14-user-owned-trust.md
iindex 7707f5ab..1eaf211c 100644
---- a/docs/cutover-t14-user-owned-trust.md
++++ b/docs/cutover-t14-user-owned-trust.md
@@@ -7,9 +7,8 @@ The data change is `migrations/0020_user_owned_trust.sql`. It drops and re-adds
wider than before. No column is added, renamed or dropped, and no row is rewritten. The migration is `phase: expand`
and every drop is `IF EXISTS`, so it is safe to run on a live production database and safe to run twice.
--Like [cutover-t2-confirmations-budgets.md](cutover-t2-confirmations-budgets.md), this is much smaller than the SQLite →
--PostgreSQL cutover ([cutover-t2-postgres.md](cutover-t2-postgres.md)): no data move, no freeze window, no snapshot
--import. The migration runs as part of the normal boot of the new release.
++Like [cutover-t2-confirmations-budgets.md](cutover-t2-confirmations-budgets.md), this has no data move,
++freeze window or snapshot import. The migration runs as part of the normal boot of the new release.
---
ddiff --git a/docs/cutover-t2-confirmations-budgets.md b/docs/cutover-t2-confirmations-budgets.md
iindex 6245762d..b91b6505 100644
---- a/docs/cutover-t2-confirmations-budgets.md
++++ b/docs/cutover-t2-confirmations-budgets.md
@@@ -6,9 +6,8 @@ This PR adds plan T2 WS-Z2 slices 4 and 5: the owner side of confirmations and a
renamed or dropped, no row is touched. Both migrations are `phase: expand` and every statement is `IF NOT EXISTS`, so
they are safe to run on a live production database and safe to run twice.
--Like [cutover-pr-openvibe-network-34.md](cutover-pr-openvibe-network-34.md) (migration 0017), this is much smaller than
--the SQLite → PostgreSQL cutover ([cutover-t2-postgres.md](cutover-t2-postgres.md)): no data move, no freeze window, no
--snapshot import. The migrations run as part of the normal boot of the new release.
++Like [cutover-pr-openvibe-network-34.md](cutover-pr-openvibe-network-34.md) (migration 0017), this has no data move,
++freeze window or snapshot import. The migrations run as part of the normal boot of the new release.
---
ddiff --git a/docs/cutover-t2-postgres.md b/docs/cutover-t2-postgres.md
ddeleted file mode 100644
iindex b8454c64..00000000
---- a/docs/cutover-t2-postgres.md
++++ /dev/null
@@@ -1,393 +0,0 @@
--# T2 cutover: Network from SQLite to PostgreSQL
--
--The production move of OpenVibe.Network from its SQLite file to PostgreSQL (plan T2, ADR-035). Every commit
--after **20dd7ff** serves from PostgreSQL only: `DATABASE_URL` is required in production (there is no SQLite
--path), migrations run as the owner on `DATABASE_DIRECT_URL`, and the limit counters live on Valkey
--(`VALKEY_URL`).
--
--**Observed state (read-only `ov access run openvibe-ovh`, 2026-10-03 UTC).** Production (`openvibe-ovh`) now
--runs **bad7b106fda0**, a PostgreSQL-only release: `health` reports it `ready` with `openvibe-network=active`
--and `validate` reports the checkout clean at that SHA. The serving code still refuses to boot in production
--without `DATABASE_URL` (`server/db/database.js`), so an active, ready release is one serving from PostgreSQL.
--`DATABASE_URL`, `DATABASE_DIRECT_URL` and `VALKEY_URL` are present and non-empty in
--`/etc/openvibe/network.env` (mode `600`). The per-check detail, and what could not be checked, is in
--[cutover-evidence-t2.md](cutover-evidence-t2.md).
--
--**First-attempt history, kept.** Production ran **20dd7ff**, the last SQLite release, until 2026-10-02. The
--deployments of the PostgreSQL-only range did not all come up: `ovhost releases network` records
--`failed-rolled-back` for **5781a6b9daf5** (2026-10-01T18:56Z), **427e196f6276** (2026-10-02T01:10Z) and
--**2c6b8b5eae59** (2026-10-02T14:49Z), each followed by a rollback to 20dd7ff. (This runbook first recorded two
--failed attempts; the release log kept by ovhost shows three.) The first release in that range that stayed up
--was **757dc98b1b67** (2026-10-02T16:56Z), then **cbaacc48bd07**, **4e7b56b2046f** and the current
--**bad7b106fda0**. The leading explanation for the rollbacks — the server refused to boot without
--`DATABASE_URL` (`server/db/database.js`) — was never confirmed and still is not: the journal no longer reaches
--those windows, and `ovhost` records `failed-rolled-back` with no reason.
--
--This runbook covers the data move those first deploys skipped. It was rehearsed with
--`npm run rehearse-pg-cutover` (`scripts/rehearse-pg-cutover.js`) on 20dd7ff's full schema: the 51 tables
--`initDb()` makes plus the 26 its modules create at boot. That rehearsal found two import blockers, fixed in
--`scripts/migrate-to-postgres.js` with it:
--
--- `analytics_rate_tracking` (openvibe-shared's old per-IP counters) has no PostgreSQL table, so the import refused
-- to start. It is now in `SKIP_SOURCE`, so IPs are never carried over.
--- The `users_profile_created` trigger fired on every imported account. That collided with the copied
-- `user_profile_changes` rows, and would have published one profile event per account. The import now turns
-- that trigger off while it copies (`QUIET_TRIGGERS`).
--
--Every step ends with a **go / no-go** check. The steps are kept as the record of how the move was rehearsed and
--run; a re-run (a rebuild, or a second environment) starts from the Prerequisites with the then-current target
--SHA. At the time of the move, a no-go before step 8 (deploy) left production on SQLite with no data change:
--restart the old release (`sudo systemctl start openvibe-network`), reopen, and reschedule. A no-go from step 8
--on meant [rollback](#rollback).
--
--Conventions:
--
--- `$STAGE` is the staging checkout of the target commit (step 1).
--- `$COPY` is the read-only SQLite copy (step 4).
--- `$T0` is when writes stopped (step 3).
--- `$T1` is when the PostgreSQL release started serving (step 8).
--- Commands that need the database URLs load `/etc/openvibe/network.env` with `node --env-file`, which never
-- prints it, as README.md does for `subscribe-events.js`. Never `cat`, `source -x`, `env` or `printenv` that file,
-- and never paste a URL into a terminal, chat or ticket.
--
-----
--
--## Prerequisites (owner only, before the window)
--
--These are done by the owner and never by an agent. Each must be true before the window opens.
--
--1. **PostgreSQL, PgBouncer and Valkey run on openvibe-ovh.** PostgreSQL 18, with PgBouncer in transaction
-- pooling mode in front of it, and Valkey. Network's database and its two roles are created by OpenVibe.Host
-- `roles/data/add-service.sh network`:
-- - an **owner** role that owns the schema, runs the migrations and does the import;
-- - a **runtime** role with DML rights only, which serves requests through PgBouncer.
--2. **The three URLs are in `/etc/openvibe/network.env`**, mode `0600`, and are never printed:
-- - `DATABASE_URL`: the runtime role, through PgBouncer.
-- - `DATABASE_DIRECT_URL`: the owner role, on a **direct** connection (not PgBouncer). Transaction pooling cannot
-- hold the import's single transaction or the migration lock.
-- - `VALKEY_URL`: the shared limit counters. Without it, each process counts on its own.
--
-- Check them by name only. The first command must print `3`, the second `600`:
--
-- ```bash
-- sudo grep -cE '^(DATABASE_URL|DATABASE_DIRECT_URL|VALKEY_URL)=.+' /etc/openvibe/network.env
-- sudo stat -c '%a' /etc/openvibe/network.env
-- ```
--3. **The URLs connect.** Run this from `$STAGE` (step 1). It prints three `ok` lines and never prints a URL:
--
-- ```bash
-- cd "$STAGE" && sudo node --env-file=/etc/openvibe/network.env -e '
-- const { Client } = require("pg");
-- (async () => {
-- for (const k of ["DATABASE_URL", "DATABASE_DIRECT_URL"]) {
-- const c = new Client({ connectionString: process.env[k] });
-- await c.connect();
-- await c.query("SELECT 1");
-- await c.end();
-- console.log(k, "ok");
-- }
-- const v = new (require("iovalkey"))(process.env.VALKEY_URL, { lazyConnect: true });
-- await v.connect();
-- console.log("VALKEY_URL", await v.ping() === "PONG" ? "ok" : "FAIL");
-- v.disconnect();
-- })().catch((e) => { console.error("FAIL", e.code || e.message.replace(/\/\/[^@]*@/, "//***@")); process.exit(1); });'
-- ```
--4. **A green rehearsal on a fresh production snapshot** (step 2). Only after it passes does the owner write
-- `~/openvibe/agents/ds/deploy/rehearsals/network-t2-pg.json` with `{"ok": true}`. ds-finish refuses to deploy
-- without that file and without the merged PR's `cutover` block.
--5. **No case-fold collisions**, or each one resolved on the SQLite side (step 5 explains how). Check this at
-- rehearsal time so the window does not stop on it.
--6. **The window is announced.** Post a status-page maintenance (admin → Status, kind `maintenance`) at least a
-- day ahead. While Network is stopped, nobody can sign in, run an OAuth authorize, or refresh a token on any
-- OpenVibe site. Services verify JWTs they already have offline (cached JWKS), so signed-in pages keep working.
-- Calls into Network fail until it is back: notifications, coins, follows and blocks. Expect about 15 minutes
-- plus the import time that the snapshot rehearsal printed (`ms`).
--7. **The target commit is fixed.** It is the merge commit of the PR carrying the `cutover` block (origin/main at
-- merge). Steps 1, 2 and 8 must all use that one SHA.
--
-----
--
--## Step 1: stage the target release (T-1 day, no downtime)
--
--At the time of the move, the running checkout `/opt/openvibe.network` stayed on 20dd7ff until step 8. The import
--tools and migrations come from a separate checkout of the target commit. It is never served from.
--
--```bash
--TARGET=<merge sha>
--STAGE=/var/tmp/network-t2-stage
--sudo -u ubuntu git -C /opt/openvibe.network fetch origin
--sudo -u ubuntu git clone --no-checkout /opt/openvibe.network "$STAGE"
--sudo -u ubuntu git -C "$STAGE" checkout --detach "$TARGET"
--cd "$STAGE" && sudo -u ubuntu npm ci # dev dependencies too: the rehearsal's PGlite lives there
--```
--
--**Go:** `git -C "$STAGE" rev-parse HEAD` equals `$TARGET`, and
--`node -e "require('better-sqlite3'); require('pg'); require('@electric-sql/pglite')"` exits 0 in `$STAGE`.
--**No-go:** `npm ci` fails. Fix the host's build tools or network first.
--
--## Step 2: rehearse on a production snapshot (T-1 day, no downtime)
--
--Take an online copy (the service keeps running) and rehearse on it, on the host, so the data never leaves it:
--
--```bash
--SNAP=/var/backups/openvibe/network-rehearsal-$(date -u +%Y%m%dT%H%M%SZ).db
--sudo -u ubuntu node -e "new (require('$STAGE/node_modules/better-sqlite3'))('/opt/openvibe.network/data/network.db', { readonly: true }).backup('$SNAP').then(() => console.log('ok'))"
--cd "$STAGE" && sudo -u ubuntu node scripts/rehearse-pg-cutover.js --sqlite "$SNAP" --store pglite
--```
--
--The live file is `/opt/openvibe.network/data/network.db` unless `DB_PATH` says otherwise. A path is not a
--secret, so `sudo grep '^DB_PATH=' /etc/openvibe/network.env` may be used to check it.
--
--The rehearsal runs, in order:
--
--1. pg-preflight;
--2. `migrate-to-postgres.js --pglite`;
--3. the same import into a PGlite directory;
--4. the row-count parity check;
--5. boots the target server on the result and requires `/api/ready` to answer 200 with `db` and `signing_key` ok.
--
--It prints one JSON line, with counts only.
--
--**Go:** exit 0 and `"ok":true`. The owner keeps that line and writes the `network-t2-pg` rehearsal file
--(prerequisite 4).
--**No-go:** read `problems` in the line.
--- `preflight` collisions: see step 5.
--- Import problems, for example a source table with no target or an unparseable date: fix them in the migrations
-- or in `TABLES`/`SKIP_SOURCE` of `scripts/migrate-to-postgres.js` with a PR, then rehearse again.
--
--`--store env` (only when given; the default never reads these URLs) runs the same rehearsal against a scratch
--PostgreSQL database instead of PGlite. Put that database's own `DATABASE_URL`/`DATABASE_DIRECT_URL` in the
--environment. The rehearsal empties its tables and refuses a target where any table holds a row (unless
--`--truncate-target`), but **never** point it at the production URLs.
--
--## Step 3: freeze writes (T0, the window opens)
--
--```bash
--sudo systemctl stop openvibe-network
--T0=$(date -u +%Y-%m-%dT%H:%M:%SZ); echo "$T0"
--```
--
--**Go:** all three hold:
--- `systemctl is-active openvibe-network` prints `inactive`;
--- `curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4000/api/health` prints `000`;
--- nothing restarts it within a minute. Check `systemctl is-active` again.
--
--**No-go:** it comes back on its own (a watchdog or a deploy). Find what restarted it before going on. A write
--made after the copy in step 4 would be lost.
--
--## Step 4: back up, and copy the SQLite file
--
--```bash
--ov access run openvibe-ovh db-backup
--COPY=/var/backups/openvibe/network-pre-t2-${T0//:/}.db
--sudo -u ubuntu node -e "new (require('$STAGE/node_modules/better-sqlite3'))('/opt/openvibe.network/data/network.db', { readonly: true }).backup('$COPY').then(() => console.log('ok'))"
--sudo chmod 0400 "$COPY" && sudo sha256sum "$COPY" | tee "$COPY.sha256"
--sudo -u ubuntu node -e "const d = new (require('$STAGE/node_modules/better-sqlite3'))('$COPY', { readonly: true }); console.log(d.pragma('integrity_check', { simple: true }))"
--```
--
--The backup API copies the file together with its WAL. From here on, every step reads `$COPY`. The live
--`data/network.db` is not opened again, and it is what a rollback serves.
--
--**Go:** all three hold:
--- `db-backup` exits 0;
--- `integrity_check` prints `ok`;
--- the user count of `$COPY` equals the live file's. Run this for each file:
-- `node -e "…prepare('SELECT COUNT(*) n FROM users').get().n"`.
--
--**No-go:** the backup fails or the integrity check is not `ok`. Restart the old release and investigate.
--
--## Step 5: pg-preflight (case-fold collisions)
--
--```bash
--cd "$STAGE" && sudo -u ubuntu npm run pg-preflight -- --sqlite "$COPY"
--```
--
--The script is `scripts/pg-preflight-collisions.js` (`--sqlite <file>`, `--json`). It opens the copy read-only.
--
--**Go:** exit 0, `no case-fold collisions: safe to import`.
--**No-go:**
--- **Exit 1 (collisions).** PostgreSQL keeps usernames unique on `lower(username)`, so `Alex` and `alex` cannot
-- both be imported. This is a data decision, never made by a script. Restart the old release and close the
-- window. For each listed pair:
-- - `users.username`: ask the newer or inactive account to rename, or rename it from the old release's admin
-- (users → edit username). That records the old name.
-- - `verification_keys.target_username` (active keys): revoke the duplicate key in admin.
-- - `username_history.old_username`: absent at 20dd7ff. The script reports `table absent`.
--
-- Run the preflight on a fresh snapshot until it is clean, then schedule a new window.
--- **Exit 2.** The file is unreadable or better-sqlite3 is missing. Fix that before going on.
--
--## Step 6: migrate-to-postgres
--
--The script reads these and nothing else:
--
--| Input | Meaning |
--|---|---|
--| `--sqlite <file>` | The source: `$COPY`. Without it, `DB_PATH`, else `data/network.db` under the checkout. Always pass it. |
--| `--pglite` | A dry run into an in-memory PostgreSQL. No URL is needed. |
--| `--json` | The report as JSON. |
--| `DATABASE_DIRECT_URL` | The owner, on a direct connection. Required without `--pglite`. `DATABASE_URL` is not used. |
--| `.env` | Read from the working directory by dotenv. `$STAGE` has none. Keep it that way. |
--
--```bash
--cd "$STAGE"
--sudo -u ubuntu node scripts/migrate-to-postgres.js --sqlite "$COPY" --pglite # dry run
--sudo node --env-file=/etc/openvibe/network.env scripts/migrate-to-postgres.js --sqlite "$COPY" # the import
--```
--
--The import does four things:
--
--1. Applies `migrations/0001…0007` as the owner.
--2. **Empties** every Network table and copies every table in one transaction, parents first. Ids are kept and
-- sequences are moved past them. `users_profile_created` is disabled for the copy and enabled again afterwards,
-- even when the copy fails. `analytics_rate_tracking` is left behind.
--3. Drops a NUL or an unpaired surrogate, which PostgreSQL refuses, and reports it as `cleaned:`.
--4. Verifies each table's row count and a checksum of every row.
--
--The SQLite copy is opened read-only. Because the import empties first, it can be repeated.
--
--**Go:** exit 0, first line `… : OK`, and no `problem:` line. A `cleaned:` line is expected and fine. Note it in
--the log.
--**No-go:** exit 1 or a `problem:` line. Leave the window closed, restart the old release, and fix the problem
--with a PR and a new rehearsal. Nothing serves from PostgreSQL yet, so there is nothing to undo. If the process
--was killed mid-import, the trigger is still off: run the import again. It ends by enabling the trigger, and
--step 7 checks that it did.
--
--## Step 7: row-count parity, per table
--
--```bash
--cd "$STAGE" && sudo node --env-file=/etc/openvibe/network.env scripts/pg-row-parity.js --sqlite "$COPY"
--```
--
--`scripts/pg-row-parity.js` reads `DATABASE_DIRECT_URL` (else `DATABASE_URL`). It compares every SQLite table:
--
--- **SQLite** (`$COPY`, read-only): `SELECT COUNT(*) FROM "<table>"` for each table in
-- `SELECT name FROM sqlite_master WHERE type = 'table'`. SQLite's own `sqlite_*` tables, FTS5 virtual tables and
-- their shadow tables, and `SKIP_SOURCE` (`analytics_rate_tracking`: old per-IP counters, never carried over)
-- are left out.
--- **PostgreSQL**: `SELECT COUNT(*) FROM "<table>"` for the same name, in
-- `SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`.
--
--PostgreSQL-only tables, new in T2, are listed and not compared. The script also requires the triggers that the
--import turned off to be enabled again:
--`SELECT tgenabled FROM pg_trigger WHERE tgname = 'users_profile_created'` must be `O`. Run the check **before** step 8. The new
--release's boot seeds rows the SQLite file never had: built-in themes, OAuth clients and principal grants.
--
--**Go:** exit 0, `OK`, and no `✗` row.
--**No-go:** any table differs, or the trigger is reported disabled. Do not deploy. Run step 6 again; it empties first. If the same table still differs,
--restart the old release and investigate.
--
--## Step 8: deploy (T1)
--
--```bash
--ov access run openvibe-ovh deploy network
--T1=$(date -u +%Y-%m-%dT%H:%M:%SZ); echo "$T1"
--```
--
--ovhost fast-forwards `/opt/openvibe.network` to the target and runs `npm install --omit=dev`. It installs the unit
--and starts it with `network.env`, polls `/api/ready`, and rolls back on its own if the release does not come up.
--At boot, the migrations find everything applied, and the seed adds only the boot rows that are missing.
--
--**Go:** the deploy exits 0, and its release is `$TARGET`.
--**No-go:**
--- **ovhost rolled back (exit 3).** At the time of the move, production was on 20dd7ff and SQLite again, and no
-- write had reached PostgreSQL.
-- Read `ovhost releases network` and the boot log
-- (`sudo journalctl -u openvibe-network --since "$T0" | tail -50`). The log names its failure without printing
-- URLs. Then close the window.
--- **Any other failure.** Go to [rollback](#rollback).
--
--## Step 9: checks
--
--Do these within 30 minutes of T1. All must pass.
--
--1. **Health.** `ov access run openvibe-ovh health network` reports healthy, on release `$TARGET`.
--2. **Readiness.** This must print `true "ok" "ok"`:
--
-- ```bash
-- curl -s https://openvibe.network/api/ready | jq '.ready, .checks.db.status, .checks.signing_key.status'
-- ```
--
-- `status` may be `degraded` only because of optional checks (`registry_poll`, `discord_bot`).
-- `failed` must be `[]`.
--3. **Sign-in.** Sign in at `https://openvibe.network/login` with an account that existed before T0. That proves
-- the imported password hashes. Then `GET /api/auth/me` returns the same username and subject id as before.
-- A session from before T0 is still signed in: same RS256 key, imported `user_sessions`.
--4. **OAuth authorize.** On a first-party site (openvibe.live, then openvibe.chat), sign out, then use "Sign in
-- with OpenVibe". `/oauth/authorize` must redirect back with a code and the site must sign you in. That proves
-- `oauth_clients`, the redirect URIs and token issuance. Also refresh a token on a site that was signed in
-- before T0.
--5. **Notifications.** `GET /api/notifications` lists the notifications from before T0, and
-- `GET /api/notifications/unread-count` answers 200. Trigger a new one, for example a follow from a second
-- account, and check that it arrives.
--6. **OpenCoins wallet.** `GET /api/coins/me` returns the same balance that `$COPY` holds for that user. Check it
-- with this, run in `$STAGE`:
--
-- ```bash
-- node -e "const d = new (require('better-sqlite3'))('$COPY', { readonly: true });
-- console.log(d.prepare('SELECT w.balance FROM wallets w JOIN users u ON u.id = w.user_id WHERE u.username = ?').get('<you>'))"
-- ```
--
--7. **Logs.** `sudo journalctl -u openvibe-network --since "$T1" -p warning` shows no database, PgBouncer or
-- Valkey errors.
--
--**Go:** all seven pass. Close the status-page maintenance. The cutover is done.
--**No-go:** any check fails and cannot be fixed within the window. Go to [rollback](#rollback).
--
--After a go, keep `$COPY` (0400) and the live `data/network.db` untouched for at least 30 days. They are the
--rollback and the audit trail. `$STAGE` may be deleted.
--
-----
--
--## Rollback
--
--```bash
--ov access run openvibe-ovh rollback network
--```
--
--At the time of the move, this brought back the previous release, **20dd7ff**, which served from
--`data/network.db` again. Nothing in this runbook writes to that file: the import read `$COPY`, and the
--PostgreSQL release never opens `DB_PATH`. The file was exactly as it was at T0. The old release ignores
--`DATABASE_URL`, `DATABASE_DIRECT_URL` and `VALKEY_URL`, so they stay in `network.env`. After the move
--succeeded, the previous release is a PostgreSQL-only one, so a rollback is a code rollback only — it does not
--by itself return serving to SQLite.
--
--**Go (rollback worked):** at the time of the move, `ov access run openvibe-ovh health network` was healthy on
--20dd7ff, and `/api/ready` answered 200. Sign-in worked.
--
--**What is lost:** every write that reached PostgreSQL between T1 and the rollback. That includes:
--- new accounts, and profile, username and password changes;
--- sessions and refresh tokens issued in that time (those people sign in again);
--- notifications, coin transactions and wallet balances;
--- follows and blocks;
--- preferences and developer projects.
--
--Events that the PostgreSQL release already published stay published. Events treats a repeated `event_id` as a
--duplicate.
--
--**Replay or accept.** Decide before anything else is attempted. A later cutover empties PostgreSQL and imports
--from a new SQLite copy, which discards these rows for good.
--
--- **Accept** when the rollback came within the window and the list below is empty or trivial. Say so on the
-- status page: "changes made between T1 and the rollback were not kept; sign in again if asked".
--- **Replay** money and accounts. On PostgreSQL, as the owner (`DATABASE_DIRECT_URL`, from `$STAGE` with
-- `node --env-file`, or `psql` on the host without echoing the URL), list them:
--
-- ```sql
-- SELECT id, user_id, app_id, delta, reason, ref, idempotency_key, created_at
-- FROM coin_transactions WHERE created_at >= '<T1>' ORDER BY id;
-- SELECT id, username, subject_id, created_at FROM users WHERE created_at >= '<T1>' ORDER BY id;
-- SELECT 'notifications', COUNT(*) FROM notifications WHERE created_at >= '<T1>'
-- UNION ALL SELECT 'user_follows', COUNT(*) FROM user_follows WHERE updated_at >= '<T1>'
-- UNION ALL SELECT 'user_blocks', COUNT(*) FROM user_blocks WHERE updated_at >= '<T1>';
-- ```
--
-- - Re-apply each coin transaction through the old release's coins API with its **same `idempotency_key`**, so a
-- second replay is a no-op. A row without a key is checked by hand against the wallet first.
-- - New accounts are told to sign up again. Their PostgreSQL subject id does not exist on SQLite.
-- - Follows and blocks that the person made again on the old release need nothing more.
--
-- Keep the query output with the incident notes. It holds usernames, so it is not posted publicly.
--
--After the rollback, read the boot log and `ovhost releases network`, fix the cause with a PR, and rehearse again
--on a fresh snapshot (step 2) before a new window.
ddiff --git a/docs/platform-plan.md b/docs/platform-plan.md
iindex 3041ee08..04b41141 100644
---- a/docs/platform-plan.md
++++ b/docs/platform-plan.md
@@@ -3,7 +3,7 @@
*2026-09-18. Supersedes the two earlier drafts (the "kernel / product domains" map and the
"finish the monorepo runtime" prompt). Both were written for a monorepo that was never built;
this plan is written for the codebase as it actually exists: one repo per property, the shared
--`openvibe-shared` package published from the Network, one host, nginx, SQLite, no Redis.*
++`openvibe-shared` package published from the Network, one host, nginx, PostgreSQL and Valkey.*
## 1. The idea, in one paragraph
@@@ -71,8 +71,7 @@ behind Live; Chat as the communication layer; content rooms (news/reviews/deals/
being source-backed and honest. Kept from the *runtime prompt*: capability-style shared
contracts, real tests over route-existence, readiness that says yellow when it is yellow.
--Changed: no monorepo, no Postgres/Redis/MinIO/Socket.IO gateway until a room needs it — the
--current SQLite + one host stack is fast and it is what is deployed. Sub-domains of the network
++Changed: no monorepo or shared Socket.IO gateway. The current PostgreSQL + Valkey stack serves the network. Sub-domains of the network
(`auth.`, `api.`, `events.`, `billing.`, `ai.`, `themes.`, `admin.`) stay **paths on
openvibe.network** (`/oauth`, `/api`, `/admin`, `/themes`) — splitting them into hosts adds
cookies and CORS and buys nothing at this size. `my.openvibe.network` already redirects to `/my`.
ddiff --git a/package-lock.json b/package-lock.json
iindex 32408cad..0d05ba81 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -10,7 +10,6 @@
"license": "MIT",
"dependencies": {
"bcryptjs": "^2.4.3",
-- "better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"discord.js": "^14.26.3",
@@@ -315,77 +314,12 @@
"safer-buffer": "^2.1.0"
}
},
-- "node_modules/base64-js": {
-- "version": "1.5.1",
-- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
-- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
-- "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/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"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==",
-- "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==",
-- "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==",
-- "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==",
-- "license": "MIT",
-- "dependencies": {
-- "inherits": "^2.0.3",
-- "string_decoder": "^1.1.1",
-- "util-deprecate": "^1.0.1"
-- },
-- "engines": {
-- "node": ">= 6"
-- }
-- },
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
@@@ -416,30 +350,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==",
-- "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",
@@@ -501,12 +411,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
-- "node_modules/chownr": {
-- "version": "1.1.4",
-- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
-- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
-- "license": "ISC"
-- },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@@ -612,30 +516,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==",
-- "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==",
-- "license": "MIT",
-- "engines": {
-- "node": ">=4.0.0"
-- }
-- },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@@ -664,15 +544,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
-- "node_modules/detect-libc": {
-- "version": "2.1.2",
-- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
-- "license": "Apache-2.0",
-- "engines": {
-- "node": ">=8"
-- }
-- },
"node_modules/discord-api-types": {
"version": "0.38.53",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz",
@@@ -759,15 +630,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==",
-- "license": "MIT",
-- "dependencies": {
-- "once": "^1.4.0"
-- }
-- },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@@ -813,15 +675,6 @@
"node": ">= 0.6"
}
},
-- "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==",
-- "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",
@@@ -905,12 +758,6 @@
],
"license": "BSD-3-Clause"
},
-- "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==",
-- "license": "MIT"
-- },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@@ -947,12 +794,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==",
-- "license": "MIT"
-- },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@@ -999,12 +840,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==",
-- "license": "MIT"
-- },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@@ -1130,38 +965,12 @@
"node": ">=0.10.0"
}
},
-- "node_modules/ieee754": {
-- "version": "1.2.1",
-- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
-- "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": "BSD-3-Clause"
-- },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"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==",
-- "license": "ISC"
-- },
"node_modules/iovalkey": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
@@@ -1416,18 +1225,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==",
-- "license": "MIT",
-- "engines": {
-- "node": ">=10"
-- },
-- "funding": {
-- "url": "https://github.com/sponsors/sindresorhus"
-- }
-- },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@@ -1455,12 +1252,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==",
-- "license": "MIT"
-- },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@@ -1486,12 +1277,6 @@
"node": ">= 6.0.0"
}
},
-- "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==",
-- "license": "MIT"
-- },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@@ -1501,18 +1286,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==",
-- "license": "MIT",
-- "dependencies": {
-- "semver": "^7.3.5"
-- },
-- "engines": {
-- "node": ">=10"
-- }
-- },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@@ -1546,15 +1319,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==",
-- "license": "ISC",
-- "dependencies": {
-- "wrappy": "1"
-- }
-- },
"node_modules/openvibe-contracts": {
"version": "0.126.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.126.0",
@@@ -1755,33 +1519,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.",
-- "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",
@@@ -1805,16 +1542,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==",
-- "license": "MIT",
-- "dependencies": {
-- "end-of-stream": "^1.1.0",
-- "once": "^1.3.1"
-- }
-- },
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@@ -1855,21 +1582,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==",
-- "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",
@@@ -2082,51 +1794,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==",
-- "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==",
-- "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/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@@ -2174,57 +1841,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==",
-- "license": "MIT",
-- "engines": {
-- "node": ">=0.10.0"
-- }
-- },
-- "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==",
-- "license": "MIT",
-- "dependencies": {
-- "chownr": "^1.1.1",
-- "mkdirp-classic": "^0.5.2",
-- "pump": "^3.0.0",
-- "tar-stream": "^2.1.4"
-- }
-- },
-- "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==",
-- "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==",
-- "license": "MIT",
-- "dependencies": {
-- "inherits": "^2.0.3",
-- "string_decoder": "^1.1.1",
-- "util-deprecate": "^1.0.1"
-- },
-- "engines": {
-- "node": ">= 6"
-- }
-- },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@@ -2246,18 +1862,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"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==",
-- "license": "Apache-2.0",
-- "dependencies": {
-- "safe-buffer": "^5.0.1"
-- },
-- "engines": {
-- "node": "*"
-- }
-- },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@@ -2357,12 +1961,6 @@
"node": ">= 16"
}
},
-- "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==",
-- "license": "ISC"
-- },
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
ddiff --git a/package.json b/package.json
iindex 2337afab..b20fb758 100644
---- a/package.json
++++ b/package.json
@@@ -9,17 +9,11 @@
"test": "node test/run.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js",
-- "test:pg": "NETWORK_TEST_STORE=pg node test/run.js",
-- "migrate-to-postgres": "node scripts/migrate-to-postgres.js",
-- "pg-preflight": "node scripts/pg-preflight-collisions.js",
-- "follows-import": "node scripts/follows-import.js",
-- "follows-preflight": "node scripts/follows-preflight.js",
-- "rehearse-pg-cutover": "node scripts/rehearse-pg-cutover.js"
++ "test:pg": "NETWORK_TEST_STORE=pg node test/run.js"
},
"license": "MIT",
"dependencies": {
"bcryptjs": "^2.4.3",
-- "better-sqlite3": "^11.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"discord.js": "^14.26.3",
ddiff --git a/scripts/analytics-prune.js b/scripts/analytics-prune.js
iindex 5b9b5bc7..23b225dc 100644
---- a/scripts/analytics-prune.js
++++ b/scripts/analytics-prune.js
@@@ -11,10 +11,8 @@
* --days <n> keep raw events newer than n days, 1..30 (default 30)
* --apply actually prune (default: dry run)
*
-- * On SQLite this was openvibe-shared/analytics/prune-cli: it made an online copy of network.db, VACUUMed
-- * it and scrubbed legacy rows. None of that survives the port — there is no database file to copy or
-- * VACUUM, and openvibe-shared's PostgreSQL path (pruneRawEventsPg) covers retention. Run it where Network
-- * runs, or with DATABASE_URL/DATABASE_DIRECT_URL pointing at a copy; it needs no elevated privileges.
++ * openvibe-shared's pruneRawEventsPg handles retention. Run it where Network runs, or with
++ * DATABASE_URL/DATABASE_DIRECT_URL pointing at a copy; it needs no elevated privileges.
*/
'use strict';
const { parseArgs } = require('./lib/db-ops');
ddiff --git a/scripts/fixtures/network-sqlite-20dd7ff.sql b/scripts/fixtures/network-sqlite-20dd7ff.sql
ddeleted file mode 100644
iindex bd7230c8..00000000
---- a/scripts/fixtures/network-sqlite-20dd7ff.sql
++++ /dev/null
@@@ -1,1063 +0,0 @@
---- OpenVibe.Network SQLite schema at commit 20dd7ff (the last SQLite release, in production before the T2 cutover).
---- Generated from that commit's server booted once on an empty file (initDb() plus the tables its modules create
---- lazily at startup: analytics, account merges/exports/deletions, history, frame cache, …): sqlite_master in
---- creation order. Read by scripts/rehearse-pg-cutover.js to build the seeded rehearsal database. Do not edit by
---- hand: it is history.
--
--CREATE TABLE users (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- username TEXT UNIQUE NOT NULL,
-- email TEXT UNIQUE,
-- password_hash TEXT NOT NULL,
-- display_name TEXT,
-- avatar_url TEXT,
-- bio TEXT DEFAULT '',
-- role TEXT DEFAULT 'user' CHECK(role IN ('user','streamer','global_mod','admin')),
-- profile_color TEXT DEFAULT '#8b5cf6',
-- is_banned INTEGER DEFAULT 0,
-- ban_reason TEXT,
-- token_valid_after TEXT DEFAULT NULL,
-- legacy_source TEXT, -- 'live' for migrated accounts
-- legacy_id INTEGER, -- original user ID in source platform
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
-- , is_anon INTEGER DEFAULT 0, anon_number INTEGER, name_effect TEXT, particle_effect TEXT, email_verified INTEGER DEFAULT 0, email_verified_at DATETIME, email_bounced_at DATETIME, email_bounce_reason TEXT, history_paused INTEGER DEFAULT 0, subject_id TEXT, merged_into INTEGER, deleted_at TEXT, profile_revision INTEGER NOT NULL DEFAULT 0);
--
--CREATE TABLE oauth_clients (
-- client_id TEXT PRIMARY KEY,
-- client_secret TEXT NOT NULL,
-- name TEXT NOT NULL,
-- redirect_uris TEXT NOT NULL, -- JSON array of allowed redirect URIs
-- is_first_party INTEGER DEFAULT 0,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- );
--
--CREATE TABLE oauth_codes (
-- code TEXT PRIMARY KEY,
-- client_id TEXT NOT NULL,
-- user_id INTEGER NOT NULL,
-- redirect_uri TEXT NOT NULL,
-- scope TEXT DEFAULT 'profile theme',
-- expires_at DATETIME NOT NULL,
-- used INTEGER DEFAULT 0,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP, code_challenge TEXT, code_challenge_method TEXT, nonce TEXT,
-- FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
-- FOREIGN KEY (user_id) REFERENCES users(id)
-- );
--
--CREATE TABLE oauth_tokens (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- token TEXT UNIQUE NOT NULL,
-- client_id TEXT NOT NULL,
-- user_id INTEGER NOT NULL,
-- scope TEXT DEFAULT 'profile theme',
-- expires_at DATETIME NOT NULL,
-- revoked INTEGER DEFAULT 0,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP, family_id TEXT, generation INTEGER NOT NULL DEFAULT 0, revoked_reason TEXT, revoked_at DATETIME,
-- FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
-- FOREIGN KEY (user_id) REFERENCES users(id)
-- );
--
--CREATE TABLE user_preferences (
-- user_id INTEGER PRIMARY KEY,
-- theme_id TEXT DEFAULT 'vibe',
-- custom_theme_variables TEXT, -- JSON: custom CSS var overrides
-- language TEXT DEFAULT 'en',
-- notifications_enabled INTEGER DEFAULT 1,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, display_prefs TEXT,
-- FOREIGN KEY (user_id) REFERENCES users(id)
-- );
--
--CREATE TABLE themes (
-- id TEXT PRIMARY KEY,
-- name TEXT NOT NULL,
-- slug TEXT UNIQUE NOT NULL,
-- author_id INTEGER,
-- description TEXT DEFAULT '',
-- mode TEXT DEFAULT 'dark' CHECK(mode IN ('dark','light')),
-- variables TEXT NOT NULL, -- JSON: CSS variable map
-- preview_colors TEXT, -- JSON: preview color swatches
-- is_builtin INTEGER DEFAULT 0,
-- is_public INTEGER DEFAULT 1,
-- downloads INTEGER DEFAULT 0,
-- rating_sum INTEGER DEFAULT 0,
-- rating_count INTEGER DEFAULT 0,
-- tags TEXT DEFAULT '[]', -- JSON array
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (author_id) REFERENCES users(id)
-- );
--
--CREATE TABLE linked_accounts (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- service TEXT NOT NULL, -- 'live', 'games', etc.
-- service_user_id TEXT NOT NULL,
-- linked_at DATETIME DEFAULT CURRENT_TIMESTAMP, service_username TEXT, last_used_at DATETIME,
-- FOREIGN KEY (user_id) REFERENCES users(id),
-- UNIQUE(service, service_user_id)
-- );
--
--CREATE TABLE audit_log (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER,
-- action TEXT NOT NULL,
-- details TEXT, -- JSON context
-- ip TEXT,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- );
--
--CREATE TABLE ip_log (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER,
-- ip TEXT NOT NULL,
-- action TEXT DEFAULT 'login',
-- country TEXT,
-- region TEXT,
-- city TEXT,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (user_id) REFERENCES users(id)
-- );
--
--CREATE TABLE site_settings (
-- key TEXT PRIMARY KEY,
-- value TEXT,
-- type TEXT DEFAULT 'string'
-- );
--
--CREATE TABLE url_registry (
-- key TEXT PRIMARY KEY,
-- label TEXT NOT NULL,
-- category TEXT NOT NULL,
-- service TEXT NOT NULL,
-- scope TEXT NOT NULL,
-- type TEXT NOT NULL,
-- value TEXT,
-- description TEXT,
-- updated_by INTEGER,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- , source TEXT NOT NULL DEFAULT 'admin');
--
--CREATE TABLE notifications (
-- id TEXT PRIMARY KEY, -- UUID
-- user_id INTEGER NOT NULL,
-- type TEXT NOT NULL, -- from TYPES enum (FOLLOW, MENTION, etc.)
-- category TEXT NOT NULL, -- social, chat, game, stream, economy, etc.
-- priority TEXT NOT NULL DEFAULT 'normal', -- low, normal, high, critical
-- title TEXT NOT NULL,
-- message TEXT,
-- icon TEXT,
-- sender_id INTEGER, -- who triggered this (nullable)
-- sender_name TEXT, -- denormalized for display
-- sender_avatar TEXT,
-- service TEXT, -- originating service (openvibelive, etc.)
-- url TEXT, -- click-through destination
-- rich_content TEXT, -- JSON: images, actions, embeds
-- is_read INTEGER DEFAULT 0,
-- is_dismissed INTEGER DEFAULT 0,
-- is_emailed INTEGER DEFAULT 0,
-- expires_at DATETIME,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE INDEX idx_notif_user_read ON notifications(user_id, is_read, created_at DESC);
--
--CREATE INDEX idx_notif_user_cat ON notifications(user_id, category);
--
--CREATE INDEX idx_notif_expires ON notifications(expires_at) WHERE expires_at IS NOT NULL;
--
--CREATE TABLE notification_preferences (
-- user_id INTEGER NOT NULL,
-- category TEXT NOT NULL, -- or '*' for global
-- enabled INTEGER DEFAULT 1,
-- sound INTEGER DEFAULT 1,
-- toasts INTEGER DEFAULT 1,
-- email INTEGER DEFAULT 0,
-- PRIMARY KEY (user_id, category),
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE TABLE email_delivery_log (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- email_type TEXT NOT NULL,
-- recipient TEXT NOT NULL,
-- subject TEXT,
-- status TEXT NOT NULL CHECK(status IN ('sent', 'failed')),
-- error_message TEXT,
-- user_id INTEGER,
-- notification_id TEXT,
-- metadata TEXT,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
-- FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE SET NULL
-- );
--
--CREATE INDEX idx_email_delivery_created ON email_delivery_log(created_at DESC);
--
--CREATE INDEX idx_email_delivery_status ON email_delivery_log(status, created_at DESC);
--
--CREATE INDEX idx_email_delivery_type ON email_delivery_log(email_type, created_at DESC);
--
--CREATE TABLE password_reset_tokens (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- token_hash TEXT UNIQUE NOT NULL,
-- expires_at DATETIME NOT NULL,
-- used_at DATETIME,
-- requested_ip TEXT,
-- requested_user_agent TEXT,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id, used_at, expires_at);
--
--CREATE INDEX idx_password_reset_expires ON password_reset_tokens(expires_at);
--
--CREATE TABLE anon_users (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- anon_number INTEGER UNIQUE NOT NULL,
-- fingerprint TEXT, -- browser fingerprint hash (optional)
-- session_token TEXT UNIQUE NOT NULL,
-- display_name TEXT,
-- preferences TEXT DEFAULT '{}', -- JSON
-- total_messages INTEGER DEFAULT 0,
-- total_commands INTEGER DEFAULT 0,
-- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
-- , ip TEXT, subject_id TEXT);
--
--CREATE TABLE user_sessions (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- session_token TEXT UNIQUE NOT NULL,
-- device_name TEXT,
-- ip TEXT,
-- user_agent TEXT,
-- is_active INTEGER DEFAULT 1,
-- last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- expires_at DATETIME NOT NULL,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE INDEX idx_sessions_user ON user_sessions(user_id, is_active);
--
--CREATE INDEX idx_sessions_token ON user_sessions(session_token);
--
--CREATE TABLE user_effects (
-- user_id INTEGER NOT NULL,
-- effect_type TEXT NOT NULL, -- 'name' or 'particle'
-- effect_id TEXT NOT NULL, -- e.g. 'rainbow', 'fire', 'neon'
-- is_active INTEGER DEFAULT 0,
-- acquired_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (user_id, effect_type, effect_id),
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE TABLE follows (
-- follower_id INTEGER NOT NULL,
-- followed_id INTEGER NOT NULL,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (follower_id, followed_id),
-- FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
-- FOREIGN KEY (followed_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE TABLE wallets (
-- user_id INTEGER PRIMARY KEY,
-- balance INTEGER NOT NULL DEFAULT 0,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE TABLE coin_transactions (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- app_id TEXT,
-- delta INTEGER NOT NULL,
-- reason TEXT,
-- ref TEXT,
-- idempotency_key TEXT UNIQUE,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE INDEX idx_coin_tx_user ON coin_transactions(user_id, created_at DESC);
--
--CREATE INDEX idx_coin_tx_app ON coin_transactions(app_id, created_at DESC);
--
--CREATE TABLE verification_keys (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- key TEXT UNIQUE NOT NULL,
-- target_username TEXT NOT NULL,
-- note TEXT DEFAULT '',
-- created_by INTEGER NOT NULL,
-- used_by INTEGER,
-- status TEXT DEFAULT 'active' CHECK(status IN ('active', 'used', 'revoked')),
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- used_at DATETIME,
-- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
-- FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
-- );
--
--CREATE INDEX idx_vkeys_key ON verification_keys(key);
--
--CREATE INDEX idx_vkeys_target ON verification_keys(target_username);
--
--CREATE INDEX idx_vkeys_status ON verification_keys(status);
--
--CREATE TABLE anon_ip_log (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- anon_id INTEGER NOT NULL,
-- ip TEXT NOT NULL,
-- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (anon_id) REFERENCES anon_users(id) ON DELETE CASCADE,
-- UNIQUE(anon_id, ip)
-- );
--
--CREATE INDEX idx_anon_ip_log_ip ON anon_ip_log(ip);
--
--CREATE INDEX idx_anon_ip_log_anon ON anon_ip_log(anon_id);
--
--CREATE TABLE push_subscriptions (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- endpoint TEXT NOT NULL UNIQUE,
-- keys_p256dh TEXT NOT NULL,
-- keys_auth TEXT NOT NULL,
-- user_agent TEXT,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE INDEX idx_push_sub_user ON push_subscriptions(user_id);
--
--CREATE INDEX idx_push_sub_endpoint ON push_subscriptions(endpoint);
--
--CREATE INDEX idx_oauth_tokens_family ON oauth_tokens(family_id);
--
--CREATE TABLE email_verification_tokens (
-- token_hash TEXT PRIMARY KEY,
-- user_id INTEGER NOT NULL,
-- email TEXT NOT NULL,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- expires_at DATETIME NOT NULL,
-- used_at DATETIME,
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
-- );
--
--CREATE INDEX idx_email_verify_user ON email_verification_tokens(user_id, created_at DESC);
--
--CREATE UNIQUE INDEX idx_linked_user_service ON linked_accounts(user_id, service);
--
--CREATE TABLE identity_legacy_map (
-- source_system TEXT NOT NULL,
-- source_type TEXT NOT NULL,
-- source_id TEXT NOT NULL,
-- subject_id TEXT NOT NULL,
-- first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- verified_at DATETIME,
-- metadata TEXT,
-- PRIMARY KEY (source_system, source_type, source_id)
-- );
--
--CREATE INDEX idx_identity_legacy_subject ON identity_legacy_map(subject_id);
--
--CREATE UNIQUE INDEX idx_users_subject_id ON users(subject_id);
--
--CREATE UNIQUE INDEX idx_anon_users_subject_id ON anon_users(subject_id);
--
--CREATE TABLE principal_grants (
-- client_id TEXT NOT NULL,
-- capability TEXT NOT NULL,
-- audience TEXT NOT NULL,
-- namespaces TEXT NOT NULL DEFAULT '[]',
-- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- granted_by TEXT,
-- revoked_at DATETIME, expires_at DATETIME, reason TEXT, revoked_by TEXT,
-- PRIMARY KEY (client_id, capability, audience)
-- );
--
--CREATE TABLE principal_usage (
-- principal TEXT NOT NULL,
-- route TEXT NOT NULL,
-- capability TEXT NOT NULL,
-- auth TEXT NOT NULL,
-- allowed INTEGER NOT NULL,
-- code TEXT NOT NULL DEFAULT '',
-- count INTEGER NOT NULL DEFAULT 0,
-- first_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- last_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- PRIMARY KEY (principal, route, auth, allowed, code)
-- );
--
--CREATE TABLE principal_grant_changes (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- client_id TEXT NOT NULL,
-- capability TEXT NOT NULL,
-- audience TEXT NOT NULL,
-- change TEXT NOT NULL CHECK (change IN ('granted', 'updated', 'revoked', 'expired')),
-- namespaces TEXT NOT NULL DEFAULT '[]',
-- expires_at TEXT,
-- reason TEXT,
-- actor TEXT,
-- event_id TEXT,
-- at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
-- );
--
--CREATE INDEX idx_grant_changes_client ON principal_grant_changes(client_id, id);
--
--CREATE TABLE user_modules (
-- subject_id TEXT NOT NULL,
-- namespace TEXT NOT NULL,
-- version INTEGER NOT NULL,
-- revision INTEGER NOT NULL DEFAULT 0,
-- data TEXT NOT NULL,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- updated_by TEXT,
-- PRIMARY KEY (subject_id, namespace)
-- );
--
--CREATE TABLE user_module_revisions (
-- subject_id TEXT NOT NULL,
-- namespace TEXT NOT NULL,
-- revision INTEGER NOT NULL,
-- PRIMARY KEY (subject_id, namespace)
-- );
--
--CREATE TABLE user_module_retirements (
-- namespace TEXT PRIMARY KEY,
-- owner TEXT NOT NULL,
-- retired_seen_at INTEGER NOT NULL
-- );
--
--CREATE TABLE network_event_outbox (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- event_id TEXT NOT NULL UNIQUE,
-- envelope TEXT NOT NULL,
-- traceparent TEXT,
-- created_at INTEGER NOT NULL,
-- attempts INTEGER NOT NULL DEFAULT 0,
-- next_attempt_at INTEGER NOT NULL DEFAULT 0,
-- sent_at INTEGER,
-- seq INTEGER,
-- rejected_at INTEGER,
-- last_error TEXT
-- );
--
--CREATE INDEX idx_network_event_outbox_due ON network_event_outbox(sent_at, rejected_at, next_attempt_at);
--
--CREATE TRIGGER user_modules_guard_users_delete BEFORE DELETE ON users
-- WHEN OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)
-- BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'); END;
--
--CREATE TRIGGER user_modules_guard_users_rekey BEFORE UPDATE OF subject_id ON users
-- WHEN OLD.subject_id IS NOT NULL AND NEW.subject_id IS NOT OLD.subject_id
-- AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)
-- BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectMerged first'); END;
--
--CREATE TRIGGER user_modules_guard_anon_users_delete BEFORE DELETE ON anon_users
-- WHEN OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)
-- BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'); END;
--
--CREATE TRIGGER user_modules_guard_anon_users_rekey BEFORE UPDATE OF subject_id ON anon_users
-- WHEN OLD.subject_id IS NOT NULL AND NEW.subject_id IS NOT OLD.subject_id
-- AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)
-- BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectMerged first'); END;
--
--CREATE TABLE dev_projects (
-- id TEXT PRIMARY KEY,
-- owner_subject TEXT NOT NULL,
-- name TEXT NOT NULL,
-- environment_policy TEXT NOT NULL DEFAULT 'sandbox' CHECK (environment_policy IN ('sandbox', 'sandbox+production')),
-- allowance TEXT NOT NULL DEFAULT '[]',
-- created_at TEXT NOT NULL,
-- created_by TEXT NOT NULL,
-- archived_at TEXT,
-- archived_by TEXT
-- );
--
--CREATE INDEX idx_dev_projects_owner ON dev_projects(owner_subject);
--
--CREATE TABLE dev_project_members (
-- project_id TEXT NOT NULL REFERENCES dev_projects(id),
-- subject_id TEXT NOT NULL,
-- role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'developer', 'viewer')),
-- added_at TEXT NOT NULL,
-- added_by TEXT NOT NULL,
-- PRIMARY KEY (project_id, subject_id)
-- );
--
--CREATE INDEX idx_dev_members_subject ON dev_project_members(subject_id);
--
--CREATE TABLE dev_apps (
-- id TEXT PRIMARY KEY,
-- project_id TEXT NOT NULL REFERENCES dev_projects(id),
-- name TEXT NOT NULL,
-- environment TEXT NOT NULL CHECK (environment IN ('sandbox', 'production')),
-- oauth_client_id TEXT NOT NULL UNIQUE,
-- client_type TEXT NOT NULL CHECK (client_type IN ('confidential', 'public')),
-- redirect_uris TEXT NOT NULL DEFAULT '[]',
-- created_at TEXT NOT NULL,
-- created_by TEXT NOT NULL,
-- revoked_at TEXT,
-- revoked_by TEXT
-- );
--
--CREATE INDEX idx_dev_apps_project ON dev_apps(project_id);
--
--CREATE TABLE dev_credentials (
-- id TEXT PRIMARY KEY,
-- app_id TEXT NOT NULL REFERENCES dev_apps(id),
-- secret_hash TEXT NOT NULL UNIQUE,
-- hint TEXT NOT NULL,
-- created_at TEXT NOT NULL,
-- created_by TEXT NOT NULL,
-- expires_at TEXT,
-- revoked_at TEXT,
-- revoked_by TEXT,
-- last_used_at TEXT
-- );
--
--CREATE INDEX idx_dev_credentials_app ON dev_credentials(app_id);
--
--CREATE TABLE dev_grants (
-- app_id TEXT NOT NULL REFERENCES dev_apps(id),
-- capability TEXT NOT NULL,
-- audience TEXT NOT NULL,
-- status TEXT NOT NULL CHECK (status IN ('requested', 'approved', 'denied', 'revoked')),
-- requested_by TEXT NOT NULL,
-- requested_at TEXT NOT NULL,
-- decided_by TEXT,
-- decided_at TEXT,
-- PRIMARY KEY (app_id, capability)
-- );
--
--CREATE TABLE dev_quotas (
-- project_id TEXT NOT NULL REFERENCES dev_projects(id),
-- capability TEXT NOT NULL,
-- limit_value INTEGER NOT NULL CHECK (limit_value >= 0),
-- quota_window TEXT NOT NULL CHECK (quota_window IN ('minute', 'hour', 'day', 'month', 'total')),
-- unit TEXT NOT NULL DEFAULT 'requests',
-- updated_at TEXT NOT NULL,
-- updated_by TEXT NOT NULL,
-- PRIMARY KEY (project_id, capability)
-- );
--
--CREATE TABLE dev_auth_codes (
-- code_hash TEXT PRIMARY KEY,
-- app_id TEXT NOT NULL REFERENCES dev_apps(id),
-- user_subject TEXT NOT NULL,
-- redirect_uri TEXT NOT NULL,
-- scope TEXT NOT NULL DEFAULT '',
-- code_challenge TEXT NOT NULL,
-- expires_at TEXT NOT NULL,
-- used INTEGER NOT NULL DEFAULT 0
-- );
--
--CREATE TABLE dev_audit (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- at TEXT NOT NULL,
-- project_id TEXT,
-- actor TEXT NOT NULL,
-- action TEXT NOT NULL,
-- target TEXT,
-- detail TEXT NOT NULL DEFAULT '{}',
-- request_id TEXT,
-- event_type TEXT,
-- event TEXT
-- );
--
--CREATE INDEX idx_dev_audit_project ON dev_audit(project_id, id);
--
--CREATE TRIGGER dev_audit_append_only_update BEFORE UPDATE ON dev_audit
-- BEGIN SELECT RAISE(ABORT, 'dev_audit is append-only'); END;
--
--CREATE TRIGGER dev_audit_append_only_delete BEFORE DELETE ON dev_audit
-- BEGIN SELECT RAISE(ABORT, 'dev_audit is append-only'); END;
--
--CREATE TABLE dev_usage_windows (
-- project_id TEXT NOT NULL,
-- env TEXT NOT NULL CHECK (env IN ('sandbox', 'production')),
-- service TEXT NOT NULL,
-- capability TEXT NOT NULL,
-- dimension TEXT NOT NULL DEFAULT '',
-- unit TEXT NOT NULL,
-- window_start TEXT NOT NULL,
-- window_end TEXT NOT NULL,
-- quantity INTEGER NOT NULL,
-- errors INTEGER NOT NULL,
-- error_codes TEXT NOT NULL DEFAULT '{}',
-- revision INTEGER NOT NULL DEFAULT 1,
-- event_id TEXT NOT NULL,
-- recorded_at TEXT NOT NULL,
-- PRIMARY KEY (project_id, env, service, capability, dimension, unit, window_start)
-- );
--
--CREATE INDEX idx_dev_usage_windows_when ON dev_usage_windows(window_start);
--
--CREATE INDEX idx_dev_usage_windows_project ON dev_usage_windows(project_id, recorded_at);
--
--CREATE TABLE dev_usage_daily (
-- project_id TEXT NOT NULL,
-- env TEXT NOT NULL,
-- service TEXT NOT NULL,
-- capability TEXT NOT NULL,
-- dimension TEXT NOT NULL DEFAULT '',
-- unit TEXT NOT NULL,
-- day TEXT NOT NULL,
-- quantity INTEGER NOT NULL DEFAULT 0,
-- errors INTEGER NOT NULL DEFAULT 0,
-- error_codes TEXT NOT NULL DEFAULT '{}',
-- updated_at TEXT NOT NULL,
-- PRIMARY KEY (project_id, env, service, capability, dimension, unit, day)
-- );
--
--CREATE INDEX idx_dev_usage_daily_day ON dev_usage_daily(project_id, day);
--
--CREATE TABLE dev_usage_errors (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- project_id TEXT NOT NULL,
-- env TEXT NOT NULL,
-- service TEXT NOT NULL,
-- capability TEXT NOT NULL,
-- at TEXT NOT NULL,
-- code TEXT NOT NULL,
-- status INTEGER,
-- trace_id TEXT,
-- ref TEXT NOT NULL DEFAULT '',
-- event_id TEXT NOT NULL
-- );
--
--CREATE UNIQUE INDEX idx_dev_usage_errors_once ON dev_usage_errors(project_id, env, service, capability, at, code, ref);
--
--CREATE INDEX idx_dev_usage_errors_recent ON dev_usage_errors(project_id, at);
--
--CREATE TABLE user_blocks (
-- blocker_subject TEXT NOT NULL,
-- blocked_subject TEXT NOT NULL,
-- active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
-- revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
-- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
-- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
-- PRIMARY KEY (blocker_subject, blocked_subject),
-- CHECK (blocker_subject <> blocked_subject)
-- );
--
--CREATE INDEX idx_user_blocks_blocked ON user_blocks(blocked_subject, active);
--
--CREATE TABLE user_follows (
-- follower_subject TEXT NOT NULL,
-- target_type TEXT NOT NULL CHECK (target_type IN ('channel')),
-- target_id TEXT NOT NULL,
-- active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
-- notify_email INTEGER NOT NULL DEFAULT 1 CHECK (notify_email IN (0, 1)),
-- notify_push INTEGER NOT NULL DEFAULT 1 CHECK (notify_push IN (0, 1)),
-- revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
-- source TEXT NOT NULL DEFAULT 'network',
-- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
-- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
-- PRIMARY KEY (follower_subject, target_type, target_id),
-- CHECK (follower_subject <> target_id)
-- );
--
--CREATE INDEX idx_user_follows_target ON user_follows(target_type, target_id, active, created_at);
--
--CREATE TABLE follow_import_holds (
-- source TEXT NOT NULL,
-- follower_ref TEXT NOT NULL,
-- target_ref TEXT NOT NULL,
-- reason TEXT NOT NULL,
-- seen_at TEXT NOT NULL,
-- PRIMARY KEY (source, follower_ref, target_ref)
-- );
--
--CREATE TABLE status_incidents (
-- id TEXT PRIMARY KEY,
-- kind TEXT NOT NULL CHECK (kind IN ('incident', 'maintenance')),
-- title TEXT NOT NULL,
-- severity TEXT CHECK (severity IS NULL OR severity IN ('minor', 'major', 'critical')),
-- state TEXT NOT NULL,
-- services TEXT NOT NULL DEFAULT '[]',
-- starts_at TEXT NOT NULL,
-- ends_at TEXT,
-- created_by TEXT NOT NULL,
-- created_at TEXT NOT NULL,
-- updated_at TEXT NOT NULL
-- );
--
--CREATE TABLE status_incident_updates (
-- incident_id TEXT NOT NULL REFERENCES status_incidents(id),
-- at TEXT NOT NULL,
-- state TEXT NOT NULL,
-- message TEXT NOT NULL,
-- author TEXT NOT NULL
-- );
--
--CREATE INDEX idx_status_incident_updates ON status_incident_updates(incident_id, at);
--
--CREATE TABLE creator_streams (
-- stream_id INTEGER PRIMARY KEY,
-- creator_subject TEXT NOT NULL,
-- title TEXT,
-- category TEXT,
-- started_at TEXT NOT NULL,
-- ended_at TEXT NOT NULL,
-- duration_seconds INTEGER NOT NULL DEFAULT 0,
-- peak_viewers INTEGER,
-- avg_viewers REAL,
-- unique_chatters INTEGER,
-- messages INTEGER,
-- watch_minutes INTEGER,
-- received_at TEXT NOT NULL
-- );
--
--CREATE INDEX idx_creator_streams_creator ON creator_streams(creator_subject, started_at);
--
--CREATE TABLE platform_nodes (
-- id TEXT PRIMARY KEY,
-- source TEXT NOT NULL,
-- doc TEXT NOT NULL,
-- status TEXT NOT NULL,
-- reported_at TEXT NOT NULL
-- );
--
--CREATE TABLE analytics_events (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- service TEXT NOT NULL,
-- event_type TEXT NOT NULL DEFAULT 'pageview',
-- path TEXT,
-- method TEXT DEFAULT 'GET',
-- status_code INTEGER,
-- response_time_ms INTEGER,
-- user_id INTEGER,
-- session_id TEXT,
-- ip TEXT,
-- country TEXT,
-- city TEXT,
-- user_agent TEXT,
-- referer TEXT,
-- is_bot INTEGER DEFAULT 0,
-- bot_type TEXT,
-- device_type TEXT,
-- browser TEXT,
-- os TEXT,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- , authenticated INTEGER NOT NULL DEFAULT 0);
--
--CREATE TABLE analytics_hourly (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- service TEXT NOT NULL,
-- hour TEXT NOT NULL,
-- pageviews INTEGER DEFAULT 0,
-- api_calls INTEGER DEFAULT 0,
-- unique_visitors INTEGER DEFAULT 0,
-- unique_users INTEGER DEFAULT 0,
-- bot_hits INTEGER DEFAULT 0,
-- avg_response_ms INTEGER DEFAULT 0,
-- error_count INTEGER DEFAULT 0,
-- bandwidth_bytes INTEGER DEFAULT 0,
-- top_paths TEXT,
-- top_referers TEXT,
-- UNIQUE(service, hour)
-- );
--
--CREATE TABLE analytics_daily (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- service TEXT NOT NULL,
-- date TEXT NOT NULL,
-- pageviews INTEGER DEFAULT 0,
-- api_calls INTEGER DEFAULT 0,
-- unique_visitors INTEGER DEFAULT 0,
-- unique_users INTEGER DEFAULT 0,
-- new_users INTEGER DEFAULT 0,
-- bot_hits INTEGER DEFAULT 0,
-- avg_response_ms INTEGER DEFAULT 0,
-- error_count INTEGER DEFAULT 0,
-- top_paths TEXT,
-- top_referers TEXT,
-- top_countries TEXT,
-- device_breakdown TEXT,
-- browser_breakdown TEXT,
-- UNIQUE(service, date)
-- );
--
--CREATE TABLE analytics_rate_tracking (
-- ip TEXT NOT NULL,
-- window_start INTEGER NOT NULL,
-- hit_count INTEGER DEFAULT 1,
-- PRIMARY KEY (ip, window_start)
-- );
--
--CREATE TABLE analytics_visitor_days (
-- service TEXT NOT NULL,
-- day TEXT NOT NULL,
-- hour TEXT NOT NULL,
-- vhash TEXT NOT NULL,
-- authenticated INTEGER NOT NULL DEFAULT 0,
-- PRIMARY KEY (service, hour, vhash)
-- );
--
--CREATE TABLE analytics_day_salts (
-- service TEXT NOT NULL,
-- day TEXT NOT NULL,
-- salt TEXT NOT NULL,
-- PRIMARY KEY (service, day)
-- );
--
--CREATE INDEX idx_analytics_events_created ON analytics_events(created_at);
--
--CREATE INDEX idx_analytics_events_service ON analytics_events(service, created_at);
--
--CREATE INDEX idx_analytics_events_path ON analytics_events(service, path, created_at);
--
--CREATE INDEX idx_analytics_events_bot ON analytics_events(is_bot, created_at);
--
--CREATE INDEX idx_analytics_hourly_lookup ON analytics_hourly(service, hour);
--
--CREATE INDEX idx_analytics_daily_lookup ON analytics_daily(service, date);
--
--CREATE INDEX idx_analytics_visitor_days_day ON analytics_visitor_days(service, day);
--
--CREATE TABLE moderation_audit (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- event_id TEXT NOT NULL UNIQUE,
-- service TEXT NOT NULL,
-- action TEXT NOT NULL,
-- actor_subject TEXT,
-- target_type TEXT,
-- target_id TEXT,
-- target_subject TEXT,
-- scope TEXT,
-- reason TEXT,
-- details TEXT NOT NULL DEFAULT '{}',
-- occurred_at TEXT NOT NULL,
-- recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
-- );
--
--CREATE INDEX idx_modaudit_when ON moderation_audit(occurred_at);
--
--CREATE INDEX idx_modaudit_service ON moderation_audit(service, id);
--
--CREATE INDEX idx_modaudit_actor ON moderation_audit(actor_subject, id);
--
--CREATE TABLE network_event_inbox (
-- consumer TEXT NOT NULL,
-- event_id TEXT NOT NULL,
-- processed_at INTEGER NOT NULL,
-- PRIMARY KEY (consumer, event_id)
-- );
--
--CREATE TABLE subject_aliases (
-- alias_id TEXT PRIMARY KEY,
-- subject_id TEXT NOT NULL,
-- merge_id TEXT NOT NULL,
-- merged_at TEXT NOT NULL,
-- merged_by TEXT NOT NULL
-- );
--
--CREATE INDEX idx_subject_aliases_subject ON subject_aliases(subject_id);
--
--CREATE TABLE account_merges (
-- id TEXT PRIMARY KEY,
-- from_subject TEXT NOT NULL UNIQUE,
-- into_subject TEXT NOT NULL,
-- from_user_id INTEGER NOT NULL,
-- into_user_id INTEGER NOT NULL,
-- initiated_by TEXT NOT NULL CHECK (initiated_by IN ('person', 'staff')),
-- actor_subject TEXT NOT NULL,
-- reason TEXT,
-- moved TEXT NOT NULL,
-- pre_state TEXT,
-- merged_at TEXT NOT NULL,
-- split_until TEXT NOT NULL,
-- reduced_at TEXT
-- );
--
--CREATE TABLE account_merge_intents (
-- id TEXT PRIMARY KEY,
-- into_user_id INTEGER NOT NULL,
-- created_at INTEGER NOT NULL,
-- expires_at INTEGER NOT NULL,
-- used_at INTEGER
-- );
--
--CREATE TABLE mod_principals (
-- mod_id TEXT PRIMARY KEY,
-- owner TEXT NOT NULL,
-- runtime TEXT NOT NULL,
-- name TEXT,
-- version TEXT,
-- publisher TEXT,
-- manifest TEXT NOT NULL,
-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
-- revision INTEGER NOT NULL DEFAULT 1,
-- created_at TEXT NOT NULL,
-- created_by TEXT,
-- updated_at TEXT NOT NULL,
-- revoked_at TEXT,
-- revoked_by TEXT
-- );
--
--CREATE TABLE mod_principal_grants (
-- mod_id TEXT NOT NULL,
-- capability TEXT NOT NULL,
-- state TEXT NOT NULL CHECK (state IN ('pending', 'approved', 'revoked')),
-- changed_at TEXT NOT NULL,
-- changed_by TEXT,
-- PRIMARY KEY (mod_id, capability)
-- );
--
--CREATE TABLE account_exports (
-- id TEXT PRIMARY KEY,
-- user_id INTEGER NOT NULL,
-- subject TEXT NOT NULL,
-- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready', 'partial', 'expired', 'failed')),
-- expected TEXT NOT NULL DEFAULT '[]',
-- requested_at TEXT NOT NULL,
-- deadline TEXT NOT NULL,
-- ready_at TEXT,
-- expires_at TEXT,
-- size_bytes INTEGER,
-- services TEXT,
-- error TEXT
-- );
--
--CREATE INDEX idx_account_exports_user ON account_exports(user_id, requested_at);
--
--CREATE TABLE account_export_parts (
-- export_id TEXT NOT NULL,
-- service TEXT NOT NULL,
-- received_at TEXT NOT NULL,
-- files INTEGER NOT NULL,
-- bytes INTEGER NOT NULL,
-- body TEXT,
-- PRIMARY KEY (export_id, service)
-- );
--
--CREATE TABLE account_deletions (
-- id TEXT PRIMARY KEY,
-- user_id INTEGER NOT NULL,
-- subject TEXT NOT NULL,
-- status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'cancelled', 'deleted')),
-- requested_at TEXT NOT NULL,
-- delete_after TEXT NOT NULL,
-- cancelled_at TEXT,
-- cancelled_by TEXT,
-- deleted_at TEXT,
-- expected TEXT,
-- erased TEXT
-- );
--
--CREATE UNIQUE INDEX idx_account_deletions_open ON account_deletions(user_id) WHERE status = 'scheduled';
--
--CREATE TABLE account_deletion_confirmations (
-- deletion_id TEXT NOT NULL,
-- service TEXT NOT NULL,
-- received_at TEXT NOT NULL,
-- completed_at TEXT NOT NULL,
-- erased TEXT NOT NULL,
-- retained TEXT NOT NULL,
-- PRIMARY KEY (deletion_id, service)
-- );
--
--CREATE TABLE user_profile_changes (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- changed TEXT NOT NULL,
-- at INTEGER NOT NULL
-- );
--
--CREATE INDEX idx_user_profile_changes_user ON user_profile_changes(user_id);
--
--CREATE TRIGGER users_profile_changed AFTER UPDATE OF username, display_name, avatar_url, profile_color, role, is_banned ON users
-- WHEN COALESCE(NEW.is_anon, 0) = 0 AND (OLD.username IS NOT NEW.username OR OLD.display_name IS NOT NEW.display_name OR OLD.avatar_url IS NOT NEW.avatar_url OR OLD.profile_color IS NOT NEW.profile_color OR OLD.role IS NOT NEW.role OR OLD.is_banned IS NOT NEW.is_banned)
-- BEGIN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, trim((CASE WHEN OLD.username IS NOT NEW.username THEN 'username ' ELSE '' END) || (CASE WHEN OLD.display_name IS NOT NEW.display_name THEN 'display_name ' ELSE '' END) || (CASE WHEN OLD.avatar_url IS NOT NEW.avatar_url THEN 'avatar_url ' ELSE '' END) || (CASE WHEN OLD.profile_color IS NOT NEW.profile_color THEN 'profile_color ' ELSE '' END) || (CASE WHEN OLD.role IS NOT NEW.role THEN 'role ' ELSE '' END) || (CASE WHEN OLD.is_banned IS NOT NEW.is_banned THEN 'banned ' ELSE '' END)), CAST(strftime('%s', 'now') AS INTEGER) * 1000); END;
--
--CREATE TRIGGER users_profile_created AFTER INSERT ON users
-- WHEN COALESCE(NEW.is_anon, 0) = 0
-- BEGIN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, 'created', CAST(strftime('%s', 'now') AS INTEGER) * 1000); END;
--
--CREATE TABLE user_history (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- service TEXT,
-- sub TEXT,
-- type TEXT NOT NULL DEFAULT 'page',
-- title TEXT NOT NULL,
-- url TEXT NOT NULL,
-- icon TEXT,
-- meta TEXT,
-- hits INTEGER NOT NULL DEFAULT 1,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- FOREIGN KEY (user_id) REFERENCES users(id)
-- );
--
--CREATE INDEX idx_user_history_user_time ON user_history(user_id, updated_at DESC);
--
--CREATE INDEX idx_user_history_user_url ON user_history(user_id, url);
--
--CREATE TABLE frame_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);
--
--CREATE TABLE frame_hits (day TEXT NOT NULL, host TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, host));
--
--CREATE TABLE tool_domains (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- tool_id TEXT NOT NULL,
-- host TEXT NOT NULL UNIQUE,
-- role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias', 'mirror')),
-- enabled INTEGER NOT NULL DEFAULT 1,
-- note TEXT,
-- created_by INTEGER,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- );
--
--CREATE INDEX idx_tool_domains_tool ON tool_domains(tool_id, role, enabled);
--
--CREATE TABLE operator_alerts (
-- source TEXT NOT NULL,
-- fingerprint TEXT NOT NULL,
-- name TEXT NOT NULL,
-- severity TEXT NOT NULL,
-- summary TEXT NOT NULL,
-- description TEXT,
-- service TEXT,
-- state TEXT NOT NULL CHECK (state IN ('firing', 'resolved')),
-- started_at TEXT NOT NULL,
-- opened_at INTEGER NOT NULL,
-- last_seen_at INTEGER NOT NULL,
-- notified_at INTEGER,
-- resolved_at INTEGER, notices TEXT, reopened INTEGER NOT NULL DEFAULT 0,
-- PRIMARY KEY (source, fingerprint)
-- );
--
--CREATE TABLE stream_live_announcements (
-- id INTEGER PRIMARY KEY AUTOINCREMENT, streamer_key TEXT NOT NULL, stream_id TEXT, sent_at DATETIME DEFAULT CURRENT_TIMESTAMP);
--
--CREATE INDEX idx_sla_key ON stream_live_announcements(streamer_key, sent_at DESC);
--
--CREATE TABLE username_history (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- old_username TEXT NOT NULL,
-- new_username TEXT NOT NULL,
-- changed_by INTEGER,
-- changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
-- );
--
--CREATE INDEX idx_username_history_old ON username_history(old_username COLLATE NOCASE);
--
--CREATE INDEX idx_username_history_user ON username_history(user_id);
--
--CREATE TABLE admin_rate_limits (
-- user_id INTEGER NOT NULL,
-- action TEXT NOT NULL,
-- last_at INTEGER NOT NULL,
-- PRIMARY KEY (user_id, action)
-- );
ddiff --git a/scripts/follows-import.js b/scripts/follows-import.js
ddeleted file mode 100644
iindex 17bd5db0..00000000
---- a/scripts/follows-import.js
++++ /dev/null
@@@ -1,107 +0,0 @@
--#!/usr/bin/env node
--/**
-- * ADR-030 backfill (plan T2 "Follows"): Live's follows into Network's user_follows, the graph go-live
-- * notifications read (server/notifications/events-consumer.js). Follows made on Live while Live's own
-- * FOLLOWS_AUTHORITY was unset never reached Network. Keep Live's direct go-live delivery active,
-- * pause Live follow writes, then do the final reconciliation before enabling Network delivery.
-- *
-- * npm run follows-import -- --live-db /opt/openvibe.live/data/live.db # dry run: counts only
-- * npm run follows-import -- --live-db /opt/openvibe.live/data/live.db --apply # import, one transaction
-- * npm run follows-import -- --live-db /opt/openvibe.live/data/live.db --reconcile --apply
-- *
-- * Live's side is read only (a readonly SQLite connection). Live user ids map to subjects through Live's
-- * linked_accounts (service 'network', subject_id), the mapping Live's own reads use. The database written is
-- * the service's own PostgreSQL (DATABASE_URL), opened without migrating or seeding. Each pair goes through importFollows()
-- * (server/identity/follows.js): no events and no notifications (Live already has these follows); a pair whose
-- * side has no subject is kept in follow_import_holds, never dropped; a pair Network already has, followed or
-- * unfollowed since, is left alone. --reconcile also deactivates Live-sourced active pairs missing from
-- * Live's snapshot; use it only with Live follow writes paused. Prints counts only.
-- */
--'use strict';
--const fs = require('fs');
--const path = require('path');
--const follows = require('../server/identity/follows');
--
--const USAGE = 'usage: npm run follows-import -- --live-db <live.db> [--reconcile] [--apply]';
--const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
--
--function parseArgs(argv) {
-- const out = {};
-- for (let i = 0; i < argv.length; i++) {
-- const a = argv[i];
-- if (a === '--apply') out.apply = true;
-- else if (a === '--reconcile') out.reconcile = true;
-- else if (a === '--help' || a === '-h') out.help = true;
-- else if (a === '--live-db') {
-- const v = argv[++i];
-- if (v === undefined || v.startsWith('--')) throw new Error('--live-db needs a value');
-- out.liveDb = v;
-- } else throw new Error(`unknown argument ${a}`);
-- }
-- return out;
--}
--
--/** A SQLite file: better-sqlite3, else Node's built-in node:sqlite (when the native module is not built for this Node). */
--function openSqlite(file, { readonly = true } = {}) {
-- try {
-- const Database = require('better-sqlite3');
-- return new Database(file, { readonly, fileMustExist: readonly });
-- } catch (e) {
-- if (!/better-sqlite3|NODE_MODULE_VERSION|Cannot find module/.test(e.message)) throw e;
-- const { DatabaseSync } = require('node:sqlite');
-- return new DatabaseSync(file, { readOnly: readonly });
-- }
--}
--
--/** Live's follows and its user id → subject map (readonly). → { rows, subjectOf } */
--function readLive(file) {
-- const live = openSqlite(file);
-- try {
-- const rows = live.prepare('SELECT follower_id, streamer_id, email_notify, push_notify, created_at FROM follows ORDER BY id').all()
-- .map((r) => ({ follower_ref: r.follower_id, target_ref: r.streamer_id, notify_email: !!r.email_notify, notify_push: !!r.push_notify, created_at: liveTime(r.created_at) }));
-- const subjects = new Map();
-- for (const r of live.prepare("SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL").all()) {
-- if (SUBJECT_RE.test(String(r.subject_id))) subjects.set(Number(r.user_id), r.subject_id);
-- }
-- return { rows, subjectOf: (id) => subjects.get(Number(id)) || null };
-- } finally { live.close(); }
--}
--
--/** SQLite's CURRENT_TIMESTAMP ('2026-01-02 03:04:05', UTC) as ISO 8601; anything unreadable → null (now). */
--function liveTime(v) {
-- if (!v) return null;
-- const s = String(v).replace(' ', 'T');
-- const t = Date.parse(/Z|[+-]\d\d:?\d\d$/.test(s) ? s : `${s}Z`);
-- return Number.isFinite(t) ? new Date(t).toISOString() : null;
--}
--
--/** Open the service database: the injected handle (tests), else DATABASE_URL as the service uses it (no migration, no seeding). */
--async function openDb(injected) {
-- if (injected) return injected;
-- if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set: the follows live in the service database');
-- const { createDb } = require('openvibe-sdk/db');
-- return createDb({ url: process.env.DATABASE_URL, service: 'network-follows-import', max: 1 });
--}
--
--async function main(argv, { db: injected, log = console.log } = {}) {
-- let args;
-- try { args = parseArgs(argv); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
-- if (args.help) { log(USAGE); return 0; }
-- if (!args.liveDb) { log(USAGE); return 2; }
-- const file = path.resolve(process.cwd(), args.liveDb);
-- if (!fs.existsSync(file)) { log(`error: ${args.liveDb} does not exist`); return 2; }
-- let live;
-- try { live = readLive(file); } catch (e) { log(`error: cannot read Live's follows: ${e.message}`); return 2; }
-- const db = await openDb(injected);
-- const out = await follows.importFollows(db, 'live', live.rows, live.subjectOf, { dryRun: !args.apply, reconcile: !!args.reconcile });
-- const held = {};
-- for (const h of out.held) held[h.reason] = (held[h.reason] || 0) + 1;
-- const why = Object.entries(held).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
-- log(`live rows ${live.rows.length}; ${args.apply ? 'imported' : 'to import'} ${out.imported}; already on Network ${out.unchanged}; ${args.apply ? 'deactivated' : 'to deactivate'} ${out.deactivated}; held: ${why}`);
-- if (!args.apply) log(out.imported || out.deactivated || out.held.length ? 'dry run: nothing changed. Re-run with --apply to make these changes.' : 'nothing to import or deactivate.');
-- return 0;
--}
--
--if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
--
--module.exports = { main, readLive, liveTime, openSqlite };
ddiff --git a/scripts/follows-preflight.js b/scripts/follows-preflight.js
ddeleted file mode 100644
iindex 4ed0020e..00000000
---- a/scripts/follows-preflight.js
++++ /dev/null
@@@ -1,51 +0,0 @@
--#!/usr/bin/env node
--/**
-- * Read-only preflight for go-live notifications from Network's own follow graph (plan T2 "Follows"): how many
-- * follows Network holds and how many imported pairs are still waiting for a subject. Counts only: no subjects,
-- * usernames or Live ids are printed. Changes nothing.
-- *
-- * npm run follows-preflight
-- *
-- * The database is the service's own PostgreSQL (DATABASE_URL), opened without migrating or seeding. A pair held in
-- * follow_import_holds is resolved by running npm run follows-import again once both sides have a subject.
-- */
--'use strict';
--
--/** → { active, channels, followers, inactive, holds: { total, by_reason: { reason: n } } } */
--async function counts(db) {
-- const f = await db.prepare(`SELECT
-- COUNT(*) FILTER (WHERE active = 1) AS active,
-- COUNT(DISTINCT target_id) FILTER (WHERE active = 1) AS channels,
-- COUNT(DISTINCT follower_subject) FILTER (WHERE active = 1) AS followers,
-- COUNT(*) FILTER (WHERE active <> 1) AS inactive
-- FROM user_follows WHERE target_type = 'channel'`).get();
-- const holds = await db.prepare('SELECT source, reason, COUNT(*) AS n FROM follow_import_holds GROUP BY source, reason ORDER BY source, reason').all();
-- const byReason = {};
-- for (const h of holds) byReason[`${h.source}:${h.reason}`] = Number(h.n);
-- return {
-- active: Number(f.active), channels: Number(f.channels), followers: Number(f.followers), inactive: Number(f.inactive),
-- holds: { total: Object.values(byReason).reduce((a, n) => a + n, 0), by_reason: byReason },
-- };
--}
--
--/** Open the service database: the injected handle (tests), else DATABASE_URL as the service uses it (no migration, no seeding). */
--async function openDb(injected) {
-- if (injected) return injected;
-- if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set: the follows live in the service database');
-- const { createDb } = require('openvibe-sdk/db');
-- return createDb({ url: process.env.DATABASE_URL, service: 'network-follows-preflight', max: 1 });
--}
--
--async function main(argv, { db: injected, log = console.log } = {}) {
-- if (argv.includes('--help') || argv.includes('-h')) { log('usage: npm run follows-preflight'); return 0; }
-- if (argv.length) { log(`unknown argument ${argv[0]}\nusage: npm run follows-preflight`); return 2; }
-- const db = await openDb(injected);
-- const c = await counts(db);
-- const why = Object.entries(c.holds.by_reason).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
-- log(`active follows ${c.active} (${c.channels} channels, ${c.followers} followers)\nunfollowed rows ${c.inactive}\nunresolved holds ${c.holds.total}${c.holds.total ? ` (${why})` : ''}`);
-- return 0;
--}
--
--if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
--
--module.exports = { main, counts };
ddiff --git a/scripts/import-blocks.js b/scripts/import-blocks.js
iindex 276deb30..b4dc861c 100644
---- a/scripts/import-blocks.js
++++ b/scripts/import-blocks.js
@@@ -26,7 +26,7 @@ const blocks = require('../server/identity/blocks');
const USAGE = 'usage: node scripts/import-blocks.js --file <json> [--apply]';
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
--/** --flag / --key value parsing (scripts/lib/db-ops.js keeps the shared one; this stays standalone). */
++/** --flag / --key value parsing (this script stays standalone). */
function parseArgs(argv, { flags = [], values = [] } = {}) {
const out = { _: [] };
for (let i = 0; i < argv.length; i++) {
ddiff --git a/scripts/lib/db-ops.js b/scripts/lib/db-ops.js
iindex 9d278f14..c0a52adf 100644
---- a/scripts/lib/db-ops.js
++++ b/scripts/lib/db-ops.js
@@@ -1,14 +1,8 @@
'use strict';
/**
* Shared by the operator scripts: `parseArgs(argv, { flags, values })`, the `--flag` / `--key value`
-- * parsing they all use. On PostgreSQL (plan T2, ADR-035) the scripts open the service database through
-- * DATABASE_URL themselves (for example, scripts/secrets-out-of-db.js); the old
-- * better-sqlite3 helpers (dbPath, dropToOwnerOf, the sqlite online backup) are gone with the file.
++ * parsing they all use. Scripts open the service database through DATABASE_URL themselves.
*/
--const path = require('path');
--
--const ROOT = path.join(__dirname, '..', '..');
--
/** --flag / --key value parsing shared by the scripts. */
function parseArgs(argv, { flags = [], values = [] } = {}) {
const out = { _: [] };
@@@ -26,4 +20,4 @@ function parseArgs(argv, { flags = [], values = [] } = {}) {
return out;
}
--module.exports = { ROOT, parseArgs };
++module.exports = { parseArgs };
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
ddeleted file mode 100644
iindex 9e1617a1..00000000
---- a/scripts/migrate-to-postgres.js
++++ /dev/null
@@@ -1,97 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * The one-time move of Network's SQLite database (DB_PATH) into its PostgreSQL schema (plan T2, ADR-035; the
-- * procedure is openvibe-sdk docs/migrating-to-postgresql.md, section 6, the steps of openvibe-sdk's runSqliteMigration).
-- * Opus runs it at the cutover, after scripts/pg-preflight-collisions.js.
-- *
-- * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
-- *
-- * --pglite a dry run: an in-memory PostgreSQL, migrations applied, every table imported with truncate
-- * and verified, and the per-table count + checksum report printed. Nothing to set up, nothing
-- * left behind. Use it to prove the schema is importable before touching a real target.
-- * --sqlite the source file (default $DB_PATH or data/network.db). It is opened read-only; nothing in it changes.
-- * --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 against a scratch DATABASE_URL is repeatable. Exit 0 only if every table verified.
-- *
-- * The legacy integer-keyed `follows` table is imported like every other: server/auth/routes.js and
-- * server/internal/routes.js still read it (the subject-keyed user_follows is the forward model).
-- *
-- * The steps are runSqliteMigration's (migrate, cleaning options, importSqlite with truncate, report), run here
-- * so QUIET_TRIGGERS can be off while the rows are copied. A run that dies between the two ALTERs leaves them
-- * off: run the import again (it re-enables them at the end), never boot on such a database.
-- */
--require('dotenv').config();
--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 config = require('../server/config');
--
--const MIGRATIONS = path.join(__dirname, '..', 'migrations');
--
--// importSqlite's per-table options. Empty: every PostgreSQL column is fed by the same-named SQLite one.
--const TABLES = {};
--
--// SQLite tables the import leaves behind. The SDK's SQLite outbox/inbox rows are carried over (unsent events
--// keep their place in the PostgreSQL outbox). Left behind:
--// analytics_rate_tracking openvibe-shared's old per-IP bot-rate counters. The rate check keeps them in
--// memory only now and PostgreSQL has no such table: IPs are never carried over.
--const SKIP_SOURCE = ['analytics_rate_tracking'];
--
--// Triggers that derive rows from an insert. The import copies those rows from SQLite as they are, so these are
--// off while it runs: users_profile_created would add a 'created' change for every imported account (a key
--// collision with the copied user_profile_changes rows, and one profile event per account at the first drain).
--const QUIET_TRIGGERS = [['users', 'users_profile_created']];
--
--// The retired SQLite file: --sqlite, else DB_PATH, else data/network.db (relative to the repo root).
--const DEFAULT_SQLITE = path.resolve(__dirname, '..', process.env.DB_PATH || path.join('data', 'network.db'));
--
--/** Migrate `owner` (a createDb() handle, the owner role) and copy `sqlite` into it. → importSqlite's report + cleaned */
--async function importInto(owner, sqlite, { log = { log() {}, warn: console.warn, error: console.error } } = {}) {
-- await owner.migrate({ dir: MIGRATIONS, log });
-- const options = await cleaningOptions(owner, TABLES);
-- for (const [table, trigger] of QUIET_TRIGGERS) await owner.exec(`ALTER TABLE ${table} DISABLE TRIGGER ${trigger}`);
-- try {
-- const report = await importSqlite({ sqlite, db: owner, truncate: true, tables: options.tables, skipSource: SKIP_SOURCE, log });
-- return { ...report, cleaned: options.cleaned() };
-- } finally {
-- for (const [table, trigger] of QUIET_TRIGGERS) await owner.exec(`ALTER TABLE ${table} ENABLE TRIGGER ${trigger}`);
-- }
--}
--
--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}`);
-- const file = path.resolve(opt('sqlite') || DEFAULT_SQLITE);
-- const quiet = { log() {}, warn: console.warn, error: console.error };
-- let owner;
-- if (flag('pglite')) owner = createDb({ pglite: true, service: 'network-import', log: quiet });
-- else {
-- if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: the import runs as the owner on a direct connection');
-- owner = createDb({ url: config.db.directUrl, service: 'network-import', max: 2, log: quiet });
-- }
-- try {
-- const t0 = Date.now();
-- const report = await importInto(owner, file, { log: quiet });
-- if (flag('json')) out(JSON.stringify({ sqlite: file, into: owner.store, ...report }, null, 2));
-- else {
-- out(`import ${file} → ${owner.store} (${Date.now() - t0} ms): ${report.ok ? 'OK' : 'PROBLEMS'}`);
-- for (const t of report.tables) out(` ${t.table.padEnd(30)} ${String(t.rows).padStart(8)} rows ${t.checksum || '-'}${t.source !== t.table ? ` (from ${t.source})` : ''}`);
-- for (const c of report.cleaned) out(` cleaned: ${c.column}: ${c.values} value(s) with a NUL or an unpaired surrogate`);
-- for (const p of report.problems) out(` problem: ${p.table}: ${p.problem}`);
-- }
-- return report.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, QUIET_TRIGGERS, MIGRATIONS, DEFAULT_SQLITE, importInto };
ddiff --git a/scripts/pg-preflight-collisions.js b/scripts/pg-preflight-collisions.js
ddeleted file mode 100644
iindex e52806ae..00000000
---- a/scripts/pg-preflight-collisions.js
++++ /dev/null
@@@ -1,67 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * Before the PostgreSQL cutover (plan T2, ADR-035): SQLite's `COLLATE NOCASE` made some columns compare
-- * case-insensitively, and username uniqueness inherited it, so `Alex` and `alex` cannot both exist here.
-- * PostgreSQL compares text case-sensitively; the port keeps the behaviour with a unique index on
-- * lower(username) and every lookup rewritten to lower(). That only works if the existing data has no two
-- * rows that fold together — this lists them, so they are fixed on the SQLite side before the import.
-- *
-- * node scripts/pg-preflight-collisions.js [--sqlite path/to/network.db] [--json]
-- *
-- * Exit 0 = no collisions; 1 = at least one (the cutover must wait); 2 = unusable input. Read-only: the
-- * SQLite file is opened in read-only mode and never written. Reports, for each case-insensitive column that
-- * must stay unique, the folding keys with more than one distinct spelling.
-- */
--require('dotenv').config();
--const fs = require('fs');
--const path = require('path');
--
--const args = process.argv.slice(2);
--const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };
--// The retired SQLite file: --sqlite, else DB_PATH, else data/network.db (relative to the repo root).
--const file = opt('--sqlite', path.resolve(__dirname, '..', process.env.DB_PATH || path.join('data', 'network.db')));
--const asJson = args.includes('--json');
--
--/** [table, column, the WHERE the uniqueness applies under, why it must not fold]. */
--const CHECKS = [
-- ['users', 'username', '1 = 1', 'users.username is UNIQUE: two spellings folding together would make the column ambiguous'],
-- ['verification_keys', 'target_username', "status = 'active'", 'an active reserved-username claim would match two people'],
-- ['username_history', 'old_username', '1 = 1', 'a former name would resolve to two accounts'],
--];
--
--function main() {
-- if (!fs.existsSync(file)) { console.error(`no such SQLite database: ${file}`); return 2; }
-- let Database;
-- try { Database = require('better-sqlite3'); } catch { console.error('better-sqlite3 is not installed; run npm install'); return 2; }
-- let db;
-- try { db = new Database(file, { readonly: true, fileMustExist: true }); } catch (e) { console.error(`cannot open ${file}: ${e.message}`); return 2; }
--
-- const report = { sqlite: file, checked: [], collisions: [] };
-- for (const [table, column, where, why] of CHECKS) {
-- const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
-- if (!exists) { report.checked.push({ table, column, rows: 0, note: 'table absent' }); continue; }
-- const rows = db.prepare(`SELECT lower(${column}) AS folded, COUNT(DISTINCT ${column}) AS spellings,
-- group_concat(DISTINCT ${column}) AS names, COUNT(*) AS rows
-- FROM ${table} WHERE ${where} GROUP BY folded HAVING COUNT(DISTINCT ${column}) > 1
-- ORDER BY folded`).all();
-- report.checked.push({ table, column, rows: db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get().n });
-- for (const r of rows) report.collisions.push({ table, column, folded: r.folded, spellings: r.spellings, names: r.names, rows: r.rows, why });
-- }
-- db.close();
--
-- if (asJson) { console.log(JSON.stringify(report, null, 2)); return report.collisions.length ? 1 : 0; }
--
-- console.log(`pg-preflight-collisions: ${file}`);
-- for (const c of report.checked) console.log(` ${c.table}.${c.column}: ${c.rows} row(s)${c.note ? ` (${c.note})` : ''}`);
-- if (!report.collisions.length) { console.log('no case-fold collisions: safe to import'); return 0; }
-- console.log(`\n${report.collisions.length} case-fold collision(s) — resolve these on the SQLite side first:`);
-- for (const c of report.collisions) {
-- console.log(` ${c.table}.${c.column}: ${c.names} (fold "${c.folded}", ${c.rows} rows)`);
-- console.log(` ${c.why}`);
-- }
-- return 1;
--}
--
--if (require.main === module) process.exit(main());
--module.exports = { CHECKS };
ddiff --git a/scripts/pg-row-parity.js b/scripts/pg-row-parity.js
ddeleted file mode 100644
iindex 99971bdf..00000000
---- a/scripts/pg-row-parity.js
++++ /dev/null
@@@ -1,99 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * The T2 cutover's row-count parity check (docs/cutover-t2-postgres.md, step 6): after
-- * scripts/migrate-to-postgres.js, every SQLite table must hold exactly as many rows in PostgreSQL. It is
-- * independent of the import's own verification (openvibe-sdk importSqlite counts and checksums what it
-- * copied): this one starts from the SQLite side, so a table the import never touched shows up as a mismatch.
-- *
-- * node scripts/pg-row-parity.js --sqlite <file> [--json] DATABASE_DIRECT_URL, else DATABASE_URL
-- * node scripts/pg-row-parity.js --sqlite <file> --pglite <dir> a PGlite directory (the rehearsal)
-- *
-- * Per table: SQLite `SELECT COUNT(*) FROM "<t>"` against PostgreSQL `SELECT COUNT(*) FROM "<t>"` (both
-- * sides read-only). Compared: every SQLite table except SQLite's own, FTS5 virtual tables and their shadow
-- * tables, and migrate-to-postgres.js's SKIP_SOURCE. PostgreSQL-only tables (new in T2) are listed, not
-- * compared. The triggers the import turns off (QUIET_TRIGGERS) must be on again. Run it before the new
-- * release boots: its boot seeds rows (OAuth clients, themes, grants) that the SQLite file never had. Prints counts only, never a row. Exit 0 = parity, 1 = a mismatch, 2 = unusable input.
-- */
--const fs = require('fs');
--const path = require('path');
--const { parseArgs } = require('./lib/db-ops');
--const { TABLES, SKIP_SOURCE, QUIET_TRIGGERS } = require('./migrate-to-postgres');
--
--const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
--const quote = (t) => { if (!NAME_RE.test(t)) throw new Error(`unexpected table name ${JSON.stringify(t)}`); return `"${t}"`; };
--
--/** The SQLite tables the import must carry over (importSqlite's own rule), each with its PostgreSQL name. */
--function sourceTables(src) {
-- const all = src.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name").all();
-- const virtuals = all.filter((t) => /^CREATE VIRTUAL TABLE/i.test(t.sql || '')).map((t) => t.name);
-- const renamed = Object.fromEntries(Object.entries(TABLES).filter(([, o]) => o && o.from).map(([to, o]) => [o.from, to]));
-- return all.map((t) => t.name)
-- .filter((n) => !n.startsWith('sqlite_') && !SKIP_SOURCE.includes(n) && !virtuals.some((v) => n === v || n.startsWith(`${v}_`)))
-- .map((n) => ({ source: n, target: renamed[n] || n }));
--}
--
--/**
-- * Compare row counts. `src` is an open better-sqlite3 Database, `db` a createDb() handle on the target.
-- * → { ok, tables: { [target]: { sqlite, pg } }, pg_only: { [table]: rows }, problems: [] }
-- */
--async function rowParity(src, db) {
-- const report = { ok: true, tables: {}, pg_only: {}, problems: [] };
-- const pgTables = new Set((await db.many(`SELECT tablename FROM pg_tables
-- WHERE schemaname = current_schema() AND tablename <> 'ov_migrations'`)).map((r) => r.tablename));
-- const compared = new Set();
-- for (const { source, target } of sourceTables(src)) {
-- const sqlite = src.prepare(`SELECT COUNT(*) AS n FROM ${quote(source)}`).get().n;
-- const pg = pgTables.has(target) ? Number(await db.value(`SELECT COUNT(*) FROM ${quote(target)}`)) : null;
-- report.tables[target] = { sqlite, pg };
-- compared.add(target);
-- if (pg === null) { report.ok = false; report.problems.push({ table: target, problem: 'no PostgreSQL table' }); }
-- else if (pg !== sqlite) { report.ok = false; report.problems.push({ table: target, problem: `row count differs: SQLite ${sqlite}, PostgreSQL ${pg}` }); }
-- }
-- for (const t of [...pgTables].sort()) if (!compared.has(t)) report.pg_only[t] = Number(await db.value(`SELECT COUNT(*) FROM ${quote(t)}`));
-- // The import turns these off while it copies; an import that died half-way leaves them off.
-- for (const [table, trigger] of QUIET_TRIGGERS) {
-- if (!pgTables.has(table)) continue;
-- const t = quote(trigger);
-- // quote() validates the name and identifier-quotes it; tgname is a text column, so the
-- // comparison takes that quoted value as a string literal (the name itself holds no quotes).
-- const state = await db.value(
-- `SELECT tgenabled::text FROM pg_trigger WHERE tgname = ${t.replace(/"/g, "'")} AND tgrelid = '${quote(table)}'::regclass`,
-- );
-- if (state !== 'O') { report.ok = false; report.problems.push({ table, problem: `trigger ${trigger} is ${state ? 'disabled' : 'missing'}: run migrate-to-postgres.js again` }); }
-- }
-- return report;
--}
--
--async function main(argv = process.argv.slice(2)) {
-- let args;
-- try { args = parseArgs(argv, { flags: ['json'], values: ['sqlite', 'pglite'] }); } catch (e) { console.error(e.message); return 2; }
-- if (args.help || !args.sqlite) { console.error('usage: node scripts/pg-row-parity.js --sqlite <file> [--pglite <dir>] [--json]'); return 2; }
-- const file = path.resolve(args.sqlite);
-- if (!fs.existsSync(file)) { console.error(`no such SQLite database: ${file}`); return 2; }
-- const url = process.env.DATABASE_DIRECT_URL || process.env.DATABASE_URL;
-- if (!args.pglite && !url) { console.error('DATABASE_DIRECT_URL (or DATABASE_URL) is not set, and no --pglite <dir>'); return 2; }
-- const { createDb } = require('openvibe-sdk/db');
-- const src = new (require('better-sqlite3'))(file, { readonly: true, fileMustExist: true });
-- const quiet = { log() {}, warn: console.warn, error: console.error };
-- const db = args.pglite ? createDb({ pglite: path.resolve(args.pglite), service: 'network-parity', log: quiet })
-- : createDb({ url, service: 'network-parity', max: 1, log: quiet });
-- try {
-- const report = await rowParity(src, db);
-- if (args.json) console.log(JSON.stringify({ sqlite: file, ...report }, null, 2));
-- else {
-- console.log(`row parity ${file} → ${db.store}: ${report.ok ? 'OK' : 'MISMATCH'}`);
-- for (const [t, c] of Object.entries(report.tables)) console.log(` ${t.padEnd(30)} ${String(c.sqlite).padStart(8)} ${String(c.pg ?? '-').padStart(8)}${c.sqlite === c.pg ? '' : ' ✗'}`);
-- for (const [t, n] of Object.entries(report.pg_only)) console.log(` ${t.padEnd(30)} ${'-'.padStart(8)} ${String(n).padStart(8)} (PostgreSQL only)`);
-- for (const p of report.problems) console.log(` problem: ${p.table}: ${p.problem}`);
-- }
-- return report.ok ? 0 : 1;
-- } finally {
-- src.close();
-- await db.close();
-- }
--}
--
--if (require.main === module) main().then((code) => process.exit(code), (err) => { console.error(`pg-row-parity failed: ${err.message}`); process.exit(2); });
--
--module.exports = { rowParity, sourceTables };
ddiff --git a/scripts/rehearse-pg-cutover.js b/scripts/rehearse-pg-cutover.js
ddeleted file mode 100644
iindex d6970ad2..00000000
---- a/scripts/rehearse-pg-cutover.js
++++ /dev/null
@@@ -1,474 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * A repeatable rehearsal of the T2 PostgreSQL cutover (docs/cutover-t2-postgres.md), end to end on scratch
-- * stores: the SQLite database → pg-preflight → migrate-to-postgres → row-count parity → the server booted on
-- * the result, /api/ready green. Nothing outside a temporary directory (and, on the containers, a database of
-- * its own, dropped at the end) is written; the SQLite file is only ever opened read-only.
-- *
-- * npm run rehearse-pg-cutover a seeded database with commit 20dd7ff's schema
-- * npm run rehearse-pg-cutover -- --sqlite /path/to/copy.db a real snapshot (a copy, never the live file)
-- * node scripts/rehearse-pg-cutover.js --write-fixture <file> only write the seeded SQLite database, then exit
-- *
-- * --store auto|pglite|containers|env where the PostgreSQL side lives (default auto: containers, else pglite;
-- * auto never uses inherited DATABASE_URLs)
-- * env only when given explicitly. DATABASE_DIRECT_URL + DATABASE_URL: a scratch database you made for
-- * this. Both must reach that same database (a probe table made through one is read through the
-- * other) and it must be empty through both. The import empties every Network table there, so
-- * a target where any table already holds a row is refused unless --truncate-target is given.
-- * Never point it at the production database.
-- * containers openvibe-sdk's test services (eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)"):
-- * OV_TEST_PG_DIRECT_URL. A throwaway database network_rehearsal_* with roles shaped like
-- * production's: the owner migrates and imports, a DML-only role (statement_timeout 15 s) serves.
-- * A database, not a schema in the shared one: an import reads information_schema, which the
-- * shared test database's thousands of test schemas slow past the 15 s query cap. The container
-- * PgBouncer routes only that shared database, so here the server connects directly (npm run
-- * test:pg covers serving through PgBouncer). OV_TEST_VALKEY_URL, when set, backs the limit
-- * counters under a prefix of its own.
-- * pglite an embedded PGlite directory: nothing to set up.
-- * --keep keep the temporary directory (its path is in the JSON line as "dir")
-- *
-- * On PostgreSQL the import is the real `node scripts/migrate-to-postgres.js --sqlite <file>` with the target's
-- * DATABASE_DIRECT_URL. PGlite has no URL, so there the script runs as its own --pglite dry run and the same
-- * import (its importInto: migrations, TABLES, SKIP_SOURCE, QUIET_TRIGGERS) fills the PGlite directory the
-- * server then boots on. On PostgreSQL the server boots with NODE_ENV=production (DATABASE_URL required, the
-- * RS256 signing key required); on PGlite with NODE_ENV=test (production refuses PGlite). Either way /api/ready
-- * must answer 200 with `db` and `signing_key` ok.
-- *
-- * Progress goes to stderr. The last line on stdout is one JSON object, counts only, never a row:
-- * {"ok":true,"tables":{"users":{"sqlite":6,"pg":6},…},"ready":{"ready":true,"status":…,"db":"ok","signing_key":"ok"},…}
-- * Exit 0 only when ok: preflight clean, import verified, every table's count equal, and ready.
-- */
--const crypto = require('crypto');
--const fs = require('fs');
--const net = require('net');
--const os = require('os');
--const path = require('path');
--const { spawn } = require('child_process');
--const { parseArgs, ROOT } = require('./lib/db-ops');
--
--const FIXTURE_SCHEMA = path.join(__dirname, 'fixtures', 'network-sqlite-20dd7ff.sql');
--const MIGRATIONS = path.join(ROOT, 'migrations');
--const log = (...a) => console.error('[rehearse]', ...a);
--
--// ── The seeded database ──────────────────────────────────────
--// Commit 20dd7ff's schema (scripts/fixtures/network-sqlite-20dd7ff.sql) with rows shaped like production's:
--// accounts (mixed-case names, an anonymous and a banned one), OAuth clients/codes/token families, notifications
--// (JSON rich content, one message holding a NUL that PostgreSQL refuses), the wallet ledger, legacy and
--// subject follows, blocks, sessions, an unsent outbox event, the profile changes 20dd7ff's users_profile_created
--// trigger writes, an old per-IP rate counter. SQLite CURRENT_TIMESTAMP text throughout.
--const SUBJECT = (n) => `usr_rehearsal${String(n).padStart(4, '0')}`;
--function seedFixture(file) {
-- const Database = require('better-sqlite3');
-- const db = new Database(file);
-- db.pragma('foreign_keys = ON');
-- db.exec(fs.readFileSync(FIXTURE_SCHEMA, 'utf8'));
-- const ins = (table, rows) => {
-- const cols = Object.keys(rows[0]);
-- const st = db.prepare(`INSERT INTO ${table} (${cols.join(', ')}) VALUES (${cols.map((c) => `@${c}`).join(', ')})`);
-- for (const r of rows) st.run(r);
-- };
-- const at = (d, h = 12) => `2026-0${d}-1${d} ${String(h).padStart(2, '0')}:0${d}:3${d}`;
-- const hash = (n) => `$2a$10$rehearsalfixturehashnotarealpasswordhash${String(n).padStart(13, '0')}`;
-- // Opaque token strings, generated per run: the fixture holds row shapes, not credentials.
-- const rtToken = (family, gen) => `rt-family-${family}-gen${gen}-${crypto.randomBytes(8).toString('hex')}`;
-- db.transaction(() => {
-- ins('users', [
-- { id: 1, username: 'alex', email: '[email protected]', password_hash: hash(1), display_name: 'Alex', role: 'admin', email_verified: 1, email_verified_at: at(1), subject_id: SUBJECT(1), created_at: at(1), updated_at: at(2), last_seen: at(3), is_anon: 0, history_paused: 0, is_banned: 0, ban_reason: null, anon_number: null },
-- { id: 2, username: 'Bea_Streams', email: '[email protected]', password_hash: hash(2), display_name: 'Bea ✨ Streams', role: 'streamer', email_verified: 1, email_verified_at: at(1), subject_id: SUBJECT(2), created_at: at(1), updated_at: at(1), last_seen: at(4), is_anon: 0, history_paused: 1, is_banned: 0, ban_reason: null, anon_number: null },
-- { id: 3, username: 'carol', email: null, password_hash: hash(3), display_name: null, role: 'user', email_verified: 0, email_verified_at: null, subject_id: SUBJECT(3), created_at: at(2), updated_at: at(2), last_seen: null, is_anon: 0, history_paused: 0, is_banned: 0, ban_reason: null, anon_number: null },
-- { id: 4, username: 'DanTheMod', email: '[email protected]', password_hash: hash(4), display_name: 'Dan', role: 'global_mod', email_verified: 1, email_verified_at: at(2), subject_id: SUBJECT(4), created_at: at(2), updated_at: at(3), last_seen: at(5), is_anon: 0, history_paused: 0, is_banned: 0, ban_reason: null, anon_number: null },
-- { id: 5, username: 'spam_account', email: '[email protected]', password_hash: hash(5), display_name: 'Spam', role: 'user', email_verified: 0, email_verified_at: null, subject_id: SUBJECT(5), created_at: at(3), updated_at: at(3), last_seen: at(3), is_anon: 0, history_paused: 0, is_banned: 1, ban_reason: 'spam', anon_number: null },
-- { id: 6, username: 'anon_7', email: null, password_hash: hash(6), display_name: 'Anon #7', role: 'user', email_verified: 0, email_verified_at: null, subject_id: SUBJECT(6), created_at: at(4), updated_at: at(4), last_seen: at(4), is_anon: 1, history_paused: 0, is_banned: 0, ban_reason: null, anon_number: 7 },
-- ]);
-- ins('oauth_clients', [
-- { client_id: 'live', client_secret: 'rehearsal-client-secret-live', name: 'OpenVibe.Live', redirect_uris: '["https://openvibe.live/auth/callback"]', is_first_party: 1, created_at: at(1) },
-- { client_id: 'chat', client_secret: 'rehearsal-client-secret-chat', name: 'OpenVibe.Chat', redirect_uris: '["https://openvibe.chat/auth/callback"]', is_first_party: 1, created_at: at(1) },
-- ]);
-- ins('oauth_codes', [
-- { code: 'code-used-1', client_id: 'live', user_id: 2, redirect_uri: 'https://openvibe.live/auth/callback', scope: 'openid profile', expires_at: at(4, 13), used: 1, code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', code_challenge_method: 'S256', nonce: 'n-1', created_at: at(4) },
-- { code: 'code-open-2', client_id: 'chat', user_id: 3, redirect_uri: 'https://openvibe.chat/auth/callback', scope: 'openid', expires_at: at(5, 13), used: 0, code_challenge: null, code_challenge_method: null, nonce: null, created_at: at(5) },
-- ]);
-- ins('oauth_tokens', [
-- { id: 1, token: rtToken('a', 1), client_id: 'live', user_id: 2, scope: 'openid profile', expires_at: at(9), revoked: 1, family_id: 'fam-a', generation: 1, revoked_reason: 'rotated', revoked_at: at(5), created_at: at(4) },
-- { id: 2, token: rtToken('a', 2), client_id: 'live', user_id: 2, scope: 'openid profile', expires_at: at(9), revoked: 0, family_id: 'fam-a', generation: 2, revoked_reason: null, revoked_at: null, created_at: at(5) },
-- { id: 3, token: rtToken('b', 1), client_id: 'chat', user_id: 1, scope: 'openid', expires_at: at(9), revoked: 0, family_id: 'fam-b', generation: 1, revoked_reason: null, revoked_at: null, created_at: at(5) },
-- ]);
-- ins('user_preferences', [
-- { user_id: 1, theme_id: 'midnight', custom_theme_variables: '{"--accent":"#8b5cf6"}', language: 'en', notifications_enabled: 1, display_prefs: '{"compact":true,"motion":"reduce"}', updated_at: at(2) },
-- { user_id: 2, theme_id: null, custom_theme_variables: null, language: 'fr', notifications_enabled: 0, display_prefs: null, updated_at: at(3) },
-- ]);
-- ins('notifications', [
-- { id: 'ntf-0001', user_id: 1, type: 'follow', category: 'social', priority: 'normal', title: 'Bea followed you', message: 'Say hi!', sender_id: 2, sender_name: 'Bea', service: 'network', url: '/u/Bea_Streams', rich_content: '{"actor":{"subject":"usr_rehearsal0002"}}', is_read: 1, is_dismissed: 0, is_emailed: 0, expires_at: null, created_at: at(5) },
-- { id: 'ntf-0002', user_id: 1, type: 'live', category: 'streams', priority: 'high', title: 'Bea is live', message: 'Speedrun night', sender_id: 2, sender_name: 'Bea', service: 'live', url: 'https://openvibe.live/Bea_Streams', rich_content: null, is_read: 0, is_dismissed: 0, is_emailed: 1, expires_at: at(9), created_at: at(6) },
-- { id: 'ntf-0003', user_id: 2, type: 'system', category: 'system', priority: 'normal', title: 'Welcome', message: 'legacy text with a NUL here:\u0000end', sender_id: null, sender_name: null, service: 'network', url: null, rich_content: '{"steps":["profile","theme"]}', is_read: 0, is_dismissed: 1, is_emailed: 0, expires_at: null, created_at: at(4) },
-- { id: 'ntf-0004', user_id: 3, type: 'coins', category: 'wallet', priority: 'low', title: 'You received 50 OpenCoins', message: null, sender_id: null, sender_name: null, service: 'network', url: '/wallet', rich_content: null, is_read: 0, is_dismissed: 0, is_emailed: 0, expires_at: null, created_at: at(6) },
-- { id: 'ntf-0005', user_id: 4, type: 'moderation', category: 'moderation', priority: 'high', title: 'Report resolved', message: 'Thanks', sender_id: null, sender_name: null, service: 'community', url: null, rich_content: null, is_read: 1, is_dismissed: 0, is_emailed: 0, expires_at: null, created_at: at(7) },
-- ]);
-- ins('notification_preferences', [
-- { user_id: 1, category: 'social', enabled: 1, sound: 0, toasts: 1, email: 0 },
-- { user_id: 1, category: 'streams', enabled: 1, sound: 1, toasts: 1, email: 1 },
-- { user_id: 2, category: 'social', enabled: 0, sound: 0, toasts: 0, email: 0 },
-- ]);
-- ins('user_sessions', [
-- { id: 1, user_id: 1, session_token: 'sess-rehearsal-1', device_name: 'Firefox on Linux', ip: null, user_agent: null, is_active: 1, last_used: at(6), created_at: at(5), expires_at: at(9) },
-- { id: 2, user_id: 2, session_token: 'sess-rehearsal-2', device_name: 'Phone', ip: null, user_agent: null, is_active: 0, last_used: at(5), created_at: at(4), expires_at: at(8) },
-- ]);
-- ins('linked_accounts', [{ id: 1, user_id: 2, service: 'twitch', service_user_id: '424242', service_username: 'bea_streams', linked_at: at(3), last_used_at: at(5) }]);
-- ins('verification_keys', [
-- { id: 1, key: 'vk-rehearsal-1', target_username: 'ReservedName', note: 'creator reservation', created_by: 1, used_by: null, status: 'active', created_at: at(3), used_at: null },
-- { id: 2, key: 'vk-rehearsal-2', target_username: 'Bea_Streams', note: null, created_by: 1, used_by: 2, status: 'used', created_at: at(1), used_at: at(1) },
-- ]);
-- ins('anon_users', [{ id: 1, anon_number: 7, fingerprint: 'fp-rehearsal', session_token: 'anon-sess-7', display_name: 'Anon #7', preferences: '{"color":"#ff0"}', total_messages: 12, total_commands: 1, first_seen: at(4), last_seen: at(5), ip: null, subject_id: 'anon_rehearsal0007' }]);
-- // The wallet ledger: balances are the sum of each account's transactions.
-- ins('coin_transactions', [
-- { id: 1, user_id: 1, app_id: 'network', delta: 100, reason: 'signup_bonus', ref: null, idempotency_key: 'signup:1', created_at: at(1) },
-- { id: 2, user_id: 2, app_id: 'network', delta: 100, reason: 'signup_bonus', ref: null, idempotency_key: 'signup:2', created_at: at(1) },
-- { id: 3, user_id: 3, app_id: 'network', delta: 100, reason: 'signup_bonus', ref: null, idempotency_key: 'signup:3', created_at: at(2) },
-- { id: 4, user_id: 2, app_id: 'live', delta: -50, reason: 'tip', ref: 'tip:ntf-0004', idempotency_key: 'tip:2:3:1', created_at: at(6) },
-- { id: 5, user_id: 3, app_id: 'live', delta: 50, reason: 'tip_received', ref: 'tip:ntf-0004', idempotency_key: 'tip:2:3:1:recv', created_at: at(6) },
-- { id: 6, user_id: 1, app_id: 'games', delta: -25, reason: 'purchase', ref: 'item:hat', idempotency_key: null, created_at: at(7) },
-- ]);
-- ins('wallets', [
-- { user_id: 1, balance: 75, updated_at: at(7) },
-- { user_id: 2, balance: 50, updated_at: at(6) },
-- { user_id: 3, balance: 150, updated_at: at(6) },
-- ]);
-- ins('follows', [
-- { follower_id: 1, followed_id: 2, created_at: at(4) },
-- { follower_id: 3, followed_id: 2, created_at: at(5) },
-- { follower_id: 2, followed_id: 1, created_at: at(5) },
-- ]);
-- ins('user_follows', [
-- { follower_subject: SUBJECT(1), target_type: 'channel', target_id: SUBJECT(2), active: 1, notify_email: 1, notify_push: 0, revision: 1, source: 'live', created_at: '2026-04-14T12:00:00.000Z', updated_at: '2026-04-14T12:00:00.000Z' },
-- { follower_subject: SUBJECT(3), target_type: 'channel', target_id: SUBJECT(2), active: 1, notify_email: 0, notify_push: 1, revision: 2, source: 'network', created_at: '2026-05-15T12:00:00.000Z', updated_at: '2026-05-16T08:00:00.000Z' },
-- { follower_subject: SUBJECT(4), target_type: 'channel', target_id: SUBJECT(2), active: 0, notify_email: 1, notify_push: 1, revision: 3, source: 'network', created_at: '2026-05-15T12:00:00.000Z', updated_at: '2026-06-01T08:00:00.000Z' },
-- ]);
-- ins('user_blocks', [
-- { blocker_subject: SUBJECT(2), blocked_subject: SUBJECT(5), active: 1, revision: 1, created_at: '2026-05-01T10:00:00.000Z', updated_at: '2026-05-01T10:00:00.000Z' },
-- { blocker_subject: SUBJECT(1), blocked_subject: SUBJECT(5), active: 0, revision: 2, created_at: '2026-05-01T10:00:00.000Z', updated_at: '2026-05-02T10:00:00.000Z' },
-- ]);
-- ins('audit_log', [{ id: 1, user_id: 1, action: 'user.ban', details: '{"target":5,"reason":"spam"}', ip: null, created_at: at(3) }]);
-- ins('site_settings', [{ key: 'registration_open', value: 'true', type: 'boolean' }]);
-- // Left behind by the import (SKIP_SOURCE): per-IP counters, never carried over.
-- ins('analytics_rate_tracking', [{ ip: '198.51.100.7', window_start: 1767268800, hit_count: 3 }]);
-- ins('network_event_outbox', [{ id: 1, event_id: 'evt-rehearsal-1', envelope: '{"type":"network.user.updated","subject":"usr_rehearsal0002"}', traceparent: null, created_at: 1767268800000, attempts: 2, next_attempt_at: 1767268860000, sent_at: null, seq: 1, rejected_at: null, last_error: 'events unreachable' }]);
-- })();
-- db.close();
--}
--
--// ── Helpers ──────────────────────────────────────────────────
--function freePort() {
-- return new Promise((resolve, reject) => {
-- const s = net.createServer();
-- s.once('error', reject);
-- s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
-- });
--}
--
--/** Run a node script from scripts/ in `cwd` (a directory with no .env) with exactly `env`. → { code, stdout, stderr } */
--function runNode(args, { cwd, env }) {
-- return new Promise((resolve) => {
-- const p = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
-- let stdout = '', stderr = '';
-- p.stdout.on('data', (d) => { stdout += d; });
-- p.stderr.on('data', (d) => { stderr += d; });
-- p.on('close', (code) => resolve({ code, stdout, stderr }));
-- });
--}
--
--/** The first JSON object a script printed (they pretty-print it), or null. */
--function jsonOf(text) {
-- const i = text.indexOf('{');
-- if (i < 0) return null;
-- try { return JSON.parse(text.slice(i)); } catch { return null; }
--}
--
--/** A URL with its password masked, for the log. */
--const masked = (url) => { try { const u = new URL(url); if (u.password) u.password = '***'; return u.toString(); } catch { return '(unparseable url)'; } };
--
--/**
-- * --store env's guard, before anything is imported. `direct` (DATABASE_DIRECT_URL, the import's connection) and
-- * `pooled` (DATABASE_URL, the server's) must reach the same database; DATABASE_URL may be a pooler, so host and port
-- * prove nothing. The target must be empty through `direct` first (read-only, so a live database is refused before
-- * anything is written). Then a probe table with a random name, made through `direct` with a random token as its
-- * comment, must show that token through `pooled` (catalogs only, so a DML-only role can read it); the probe is
-- * dropped either way. Then the target must be empty through `pooled` too. Throws with a message naming no URL.
-- */
--async function checkScratchTarget(direct, pooled, { truncateTarget = false } = {}) {
-- const assertEmpty = async (name, db) => {
-- if (truncateTarget) return;
-- // A scratch database holds no rows anywhere (ov_migrations aside): any row means it is not one.
-- const tables = (await db.many(`SELECT tablename FROM pg_tables
-- WHERE schemaname = current_schema() AND tablename <> 'ov_migrations'`)).map((r) => r.tablename);
-- const held = [];
-- for (const t of tables) if (await db.value(`SELECT EXISTS (SELECT 1 FROM "${t.replace(/"/g, '""')}")`)) held.push(t);
-- if (held.length) throw new Error(`the target is not empty through ${name} (rows in ${held.length} table(s): ${held.slice(0, 5).join(', ')}${held.length > 5 ? ', …' : ''}); the import empties it. Give --truncate-target if it really is a scratch database`);
-- };
-- await assertEmpty('DATABASE_DIRECT_URL', direct);
-- const probe = `ov_rehearsal_probe_${crypto.randomBytes(6).toString('hex')}`;
-- const token = crypto.randomBytes(16).toString('hex');
-- let seen = null;
-- try {
-- await direct.exec(`CREATE TABLE ${probe} (id int)`);
-- await direct.exec(`COMMENT ON TABLE ${probe} IS '${token}'`);
-- seen = await pooled.value(`SELECT obj_description(c.oid, 'pg_class') FROM pg_class c WHERE c.relname = '${probe}'`)
-- .catch((e) => { throw new Error(`reading the probe through DATABASE_URL failed: ${e.message}`); });
-- } finally {
-- await direct.exec(`DROP TABLE IF EXISTS ${probe}`).catch((e) => log(`probe cleanup: ${e.message}`));
-- }
-- if (seen !== token) throw new Error('DATABASE_URL does not reach the database DATABASE_DIRECT_URL imports into (a probe table made through DATABASE_DIRECT_URL is not visible through DATABASE_URL); --store env needs both on one scratch database');
-- await assertEmpty('DATABASE_URL', pooled);
--}
--
--/** The PostgreSQL side: { store, directUrl?, url?, pgliteDir?, valkeyUrl?, close() }. */
--async function openTarget(store, dir, truncateTarget) {
-- if (store === 'env') {
-- const directUrl = (process.env.DATABASE_DIRECT_URL || '').trim(), url = (process.env.DATABASE_URL || '').trim();
-- if (!directUrl || !url) throw new Error(`--store env needs DATABASE_DIRECT_URL and DATABASE_URL, both on one scratch database (${directUrl ? 'DATABASE_URL' : 'DATABASE_DIRECT_URL'} is unset or empty)`);
-- const { createDb } = require('openvibe-sdk/db');
-- const silent = { log() {}, warn() {}, error() {} };
-- const direct = createDb({ url: directUrl, service: 'network-rehearsal', max: 1, log: silent });
-- const pooled = createDb({ url, service: 'network-rehearsal-pooled', max: 1, log: silent });
-- try { await checkScratchTarget(direct, pooled, { truncateTarget }); }
-- finally { await Promise.allSettled([direct.close(), pooled.close()]); }
-- log(`target: DATABASE_DIRECT_URL ${masked(directUrl)}, DATABASE_URL ${masked(url)} (the same database)`);
-- return { store: 'postgresql', directUrl, url, valkeyUrl: process.env.VALKEY_URL || null, close: async () => {} };
-- }
-- if (store === 'containers') {
-- if (!process.env.OV_TEST_PG_DIRECT_URL) throw new Error('--store containers needs OV_TEST_PG_DIRECT_URL (eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)")');
-- return scratchDatabase(process.env.OV_TEST_PG_DIRECT_URL);
-- }
-- const pgliteDir = path.join(dir, 'pglite');
-- log(`target: PGlite ${pgliteDir}`);
-- return { store: 'pglite', pgliteDir, valkeyUrl: null, close: async () => {} };
--}
--
--/**
-- * A database of its own on the containers' PostgreSQL (`superUrl`, a superuser on a direct connection), shaped as
-- * OpenVibe.Host's roles/data/add-service.sh makes production's: an owner role owning the database and its public
-- * schema (migrations, the import) and a runtime role with DML only. close() drops the database and both roles.
-- */
--async function scratchDatabase(superUrl) {
-- const { createDb } = require('openvibe-sdk/db');
-- const silent = { log() {}, warn() {}, error() {} };
-- const name = `network_rehearsal_${process.pid}_${crypto.randomBytes(4).toString('hex')}`;
-- const owner = `${name}_owner`;
-- const pw = crypto.randomBytes(16).toString('hex');
-- const as = (user, db = name) => { const u = new URL(superUrl); u.username = user; u.password = pw; u.pathname = `/${db}`; return u.toString(); };
-- // DROP DATABASE … WITH (FORCE) on the shared, busy container can outlast the 15 s default cap.
-- const su = createDb({ url: superUrl, service: 'network-rehearsal-admin', max: 1, queryTimeoutMs: 120000, log: silent });
-- const drop = async () => {
-- await su.exec(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
-- for (const r of [name, owner]) await su.exec(`DROP ROLE IF EXISTS ${r}`);
-- };
-- try {
-- for (const stmt of [
-- `CREATE ROLE ${owner} LOGIN PASSWORD '${pw}'`,
-- `CREATE ROLE ${name} LOGIN PASSWORD '${pw}'`,
-- `ALTER ROLE ${name} SET statement_timeout = '15s'`,
-- `ALTER ROLE ${name} SET lock_timeout = '5s'`,
-- `CREATE DATABASE ${name} OWNER ${owner}`,
-- `REVOKE ALL ON DATABASE ${name} FROM PUBLIC`,
-- `GRANT CONNECT ON DATABASE ${name} TO ${owner}, ${name}`,
-- ]) await su.exec(stmt);
-- const o = createDb({ url: as(owner), service: 'network-rehearsal-owner', max: 1, log: silent });
-- try {
-- for (const stmt of [
-- `GRANT USAGE ON SCHEMA public TO ${name}`,
-- `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${name}`,
-- `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${name}`,
-- ]) await o.exec(stmt);
-- } finally { await o.close(); }
-- } catch (e) { await drop().catch(() => {}); await su.close(); throw e; }
-- log(`target: test-services container, database ${name}`);
-- return {
-- store: 'postgresql', directUrl: as(owner), url: as(name), valkeyUrl: process.env.OV_TEST_VALKEY_URL || null,
-- close: async () => { try { await drop(); } finally { await su.close(); } },
-- };
--}
--
--/** PGlite: migrate-to-postgres.js's import, into the directory the server then boots on. */
--async function importIntoPglite(sqlite, pgliteDir) {
-- const { createDb } = require('openvibe-sdk/db');
-- const { importInto } = require('./migrate-to-postgres');
-- const owner = createDb({ pglite: pgliteDir, service: 'network-import', log: { log() {}, warn: console.error, error: console.error } });
-- try { return await importInto(owner, sqlite); } finally { await owner.close(); }
--}
--
--/**
-- * GET `${base}/api/ready` until it answers 200 with ready, stop() is true, or timeoutMs passes. Each request, body
-- * included, is aborted after min(the time left, requestMs), so a server that accepts and never answers cannot hold
-- * the rehearsal past its deadline. → { status, body } of the last answer read (null when none was).
-- */
--async function pollReady(base, { timeoutMs, stop = () => false, requestMs = 5000 }) {
-- const t0 = Date.now();
-- let status = null, body = null;
-- while (!stop()) {
-- const left = timeoutMs - (Date.now() - t0);
-- if (left <= 0) break;
-- try {
-- const r = await fetch(`${base}/api/ready`, { signal: AbortSignal.timeout(Math.max(1, Math.min(left, requestMs))) });
-- status = r.status;
-- body = await r.json();
-- if (r.status === 200 && body.ready) break;
-- } catch { /* not listening yet, aborted at the deadline, or not JSON */ }
-- await new Promise((r) => setTimeout(r, 250));
-- }
-- return { status, body };
--}
--
--/** Boot server/index.js on the target and read /api/ready until it is ready (or the deadline). */
--async function bootAndCheck(target, dir, { timeoutMs = 120000 } = {}) {
-- const port = await freePort();
-- const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
-- fs.mkdirSync(path.join(dir, 'keys'), { recursive: true });
-- fs.writeFileSync(path.join(dir, 'keys', 'private.pem'), keys.privateKey, { mode: 0o600 });
-- fs.writeFileSync(path.join(dir, 'keys', 'public.pem'), keys.publicKey);
-- const env = {
-- PATH: process.env.PATH, HOME: dir, PORT: String(port), HOST: '127.0.0.1',
-- NODE_ENV: target.store === 'pglite' ? 'test' : 'production',
-- JWT_PRIVATE_KEY: path.join(dir, 'keys', 'private.pem'), JWT_PUBLIC_KEY: path.join(dir, 'keys', 'public.pem'),
-- AVATAR_PATH: path.join(dir, 'avatars'),
-- OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,
-- OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9', OV_TOOLS_INTERNAL_URL: 'http://127.0.0.1:9', OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9',
-- OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9', OV_AI_INTERNAL_URL: 'http://127.0.0.1:9',
-- ...(target.store === 'pglite' ? { PGLITE_DIR: target.pgliteDir } : { DATABASE_URL: target.url, DATABASE_DIRECT_URL: target.directUrl }),
-- ...(target.valkeyUrl ? { VALKEY_URL: target.valkeyUrl, VALKEY_PREFIX: `ov:network-rehearsal-${crypto.randomBytes(4).toString('hex')}:` } : {}),
-- };
-- const proc = spawn(process.execPath, [path.join(ROOT, 'server', 'index.js')], { cwd: dir, env, stdio: ['ignore', 'pipe', 'pipe'] });
-- let out = '';
-- proc.stdout.on('data', (d) => { out += d; });
-- proc.stderr.on('data', (d) => { out += d; });
-- let exited = null;
-- proc.on('exit', (code, sig) => { exited = { code, sig }; });
-- const base = `http://127.0.0.1:${port}`;
-- const t0 = Date.now();
-- let body = null, status = null;
-- try {
-- ({ status, body } = await pollReady(base, { timeoutMs, stop: () => !!exited }));
-- } finally {
-- if (!exited) {
-- await new Promise((resolve) => {
-- const timer = setTimeout(resolve, 5000);
-- proc.once('exit', () => { clearTimeout(timer); resolve(); });
-- proc.kill('SIGTERM');
-- });
-- if (!exited) {
-- try { proc.kill('SIGKILL'); } catch { /* gone */ }
-- await new Promise((resolve) => { if (exited) resolve(); else proc.once('exit', resolve); });
-- }
-- }
-- }
-- const checks = (body && body.checks) || {};
-- const ready = {
-- http: status, ready: !!(body && body.ready), status: body ? body.status : null,
-- db: checks.db ? checks.db.status : null, signing_key: checks.signing_key ? checks.signing_key.status : null,
-- failed: body ? body.failed : null, degraded: body ? body.degraded : null, node_env: env.NODE_ENV, valkey: !!target.valkeyUrl, ms: Date.now() - t0,
-- };
-- ready.ok = status === 200 && ready.ready && ready.db === 'ok' && ready.signing_key === 'ok';
-- if (!ready.ok) {
-- ready.exited = exited;
-- // The boot log's tail, for the operator; the server never logs secrets or rows.
-- log(`server did not become ready (${JSON.stringify(exited)}); last log lines:\n${out.split('\n').slice(-25).join('\n')}`);
-- }
-- return ready;
--}
--
--// ── The rehearsal ────────────────────────────────────────────
--async function rehearse(opts) {
-- const t0 = Date.now();
-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-network-rehearsal-'));
-- const result = { ok: false, source: opts.sqlite ? 'snapshot' : 'seeded-20dd7ff', store: null, preflight: null, import: null, tables: {}, pg_only: {}, ready: null, problems: [], dir: opts.keep ? dir : undefined };
-- // Children get exactly this environment, in a directory with no .env, so nothing else is picked up.
-- const childEnv = { PATH: process.env.PATH, HOME: dir };
-- let target = null;
-- try {
-- const sqlite = opts.sqlite ? path.resolve(opts.sqlite) : path.join(dir, 'network.db');
-- if (opts.sqlite) { if (!fs.existsSync(sqlite)) throw new Error(`no such SQLite database: ${sqlite}`); }
-- else seedFixture(sqlite);
-- log(`source: ${sqlite}`);
--
-- // 1. pg-preflight: case-fold collisions block the cutover.
-- const pre = await runNode([path.join(__dirname, 'pg-preflight-collisions.js'), '--sqlite', sqlite, '--json'], { cwd: dir, env: childEnv });
-- const preReport = jsonOf(pre.stdout);
-- result.preflight = { ok: pre.code === 0, exit: pre.code, collisions: preReport ? preReport.collisions.map((c) => ({ table: c.table, column: c.column, spellings: c.spellings })) : null };
-- if (pre.code !== 0) { result.problems.push(`pg-preflight exited ${pre.code}${pre.stderr ? `: ${pre.stderr.trim().split('\n').pop()}` : ''}`); return result; }
-- log('preflight: no case-fold collisions');
--
-- // 2. migrate-to-postgres into the scratch target.
-- const store = opts.store === 'auto'
-- ? (process.env.OV_TEST_PG_DIRECT_URL ? 'containers' : 'pglite')
-- : opts.store;
-- target = await openTarget(store, dir, opts['truncate-target']);
-- result.store = target.store;
-- let imp;
-- if (target.store === 'pglite') {
-- const dry = await runNode([path.join(__dirname, 'migrate-to-postgres.js'), '--sqlite', sqlite, '--pglite', '--json'], { cwd: dir, env: childEnv });
-- const dryReport = jsonOf(dry.stdout);
-- if (dry.code !== 0) result.problems.push(`migrate-to-postgres --pglite exited ${dry.code}: ${dryReport ? JSON.stringify(dryReport.problems) : dry.stderr.trim().split('\n').pop()}`);
-- imp = await importIntoPglite(sqlite, target.pgliteDir);
-- imp.script = { exit: dry.code };
-- } else {
-- const run = await runNode([path.join(__dirname, 'migrate-to-postgres.js'), '--sqlite', sqlite, '--json'], { cwd: dir, env: { ...childEnv, DATABASE_DIRECT_URL: target.directUrl } });
-- imp = jsonOf(run.stdout) || { ok: false, tables: [], problems: [{ table: '-', problem: run.stderr.trim().split('\n').pop() || `exit ${run.code}` }] };
-- imp.script = { exit: run.code };
-- }
-- result.import = { ok: imp.ok && imp.script.exit === 0, tables: imp.tables.length, rows: imp.tables.reduce((n, t) => n + t.rows, 0), cleaned: imp.cleaned || [], problems: imp.problems };
-- if (!result.import.ok) { result.problems.push('migrate-to-postgres did not verify'); return result; }
-- log(`import: ${result.import.tables} tables, ${result.import.rows} rows verified`);
--
-- // 3. Row-count parity, before the server's boot seeds anything.
-- const { rowParity } = require('./pg-row-parity');
-- const { createDb } = require('openvibe-sdk/db');
-- const quiet = { log() {}, warn: console.error, error: console.error };
-- const src = new (require('better-sqlite3'))(sqlite, { readonly: true, fileMustExist: true });
-- const db = target.store === 'pglite' ? createDb({ pglite: target.pgliteDir, service: 'network-parity', log: quiet })
-- : createDb({ url: target.directUrl, service: 'network-parity', max: 1, log: quiet });
-- let parity;
-- try { parity = await rowParity(src, db); } finally { src.close(); await db.close(); }
-- result.tables = parity.tables;
-- result.pg_only = parity.pg_only;
-- for (const p of parity.problems) result.problems.push(`${p.table}: ${p.problem}`);
-- if (!parity.ok) return result;
-- log(`parity: ${Object.keys(parity.tables).length} tables equal`);
--
-- // 4. The server on the imported database: /api/ready with db and signing_key ok.
-- result.ready = await bootAndCheck(target, dir, { timeoutMs: opts.timeoutMs });
-- if (!result.ready.ok) result.problems.push(`/api/ready: ${result.ready.http} ${result.ready.status} (failed: ${JSON.stringify(result.ready.failed)})`);
-- else log(`ready: ${result.ready.status} in ${result.ready.ms} ms`);
-- result.ok = result.preflight.ok && result.import.ok && parity.ok && result.ready.ok && result.problems.length === 0;
-- return result;
-- } catch (err) {
-- result.problems.push(err.message);
-- return result;
-- } finally {
-- if (target) await target.close().catch((e) => log(`cleanup: ${e.message}`));
-- if (!opts.keep) fs.rmSync(dir, { recursive: true, force: true });
-- result.ms = Date.now() - t0;
-- }
--}
--
--async function main(argv = process.argv.slice(2)) {
-- let opts;
-- try { opts = parseArgs(argv, { flags: ['keep', 'truncate-target'], values: ['sqlite', 'store', 'write-fixture', 'timeout'] }); } catch (e) { console.error(e.message); return 2; }
-- if (opts.help) { console.error('usage: node scripts/rehearse-pg-cutover.js [--sqlite <copy.db>] [--store auto|pglite|containers|env] [--truncate-target] [--keep] [--write-fixture <file>]'); return 2; }
-- if (opts['write-fixture']) {
-- const file = path.resolve(opts['write-fixture']);
-- if (fs.existsSync(file)) { console.error(`${file} exists; not overwriting it`); return 2; }
-- seedFixture(file);
-- console.error(`wrote ${file}`);
-- return 0;
-- }
-- opts.store = opts.store || 'auto';
-- if (!['auto', 'pglite', 'containers', 'env'].includes(opts.store)) { console.error(`--store ${opts.store}: auto, pglite, containers or env`); return 2; }
-- opts.timeoutMs = opts.timeout ? Number(opts.timeout) * 1000 : 120000;
-- const result = await rehearse(opts);
-- if (result.dir === undefined) delete result.dir;
-- console.log(JSON.stringify(result));
-- return result.ok ? 0 : 1;
--}
--
--if (require.main === module) main().then((code) => process.exit(code), (err) => { console.log(JSON.stringify({ ok: false, problems: [err.message] })); process.exit(1); });
--
--module.exports = { rehearse, seedFixture, FIXTURE_SCHEMA, checkScratchTarget, pollReady };
ddiff --git a/scripts/secrets-out-of-db.js b/scripts/secrets-out-of-db.js
iindex 1e557fd3..dde6ffa4 100644
---- a/scripts/secrets-out-of-db.js
++++ b/scripts/secrets-out-of-db.js
@@@ -23,8 +23,8 @@
* rollback: puts the backed-up value back into each secret setting that is blank now
*
* On PostgreSQL (plan T2, ADR-035) the settings live in the service database: the script reads and writes
-- * them through DATABASE_URL, exactly as the service does. There is no separate owner process and no
-- * sqlite online backup; --backup writes a JSON file (0600) holding the values being blanked.
++ * them through DATABASE_URL, exactly as the service does. --backup writes a JSON file (0600)
++ * holding the values being blanked.
*
* --env-file <path> default /etc/openvibe/network.env
* --unit <name> systemd unit whose running process must already have the variables
ddiff --git a/scripts/subscribe-events.js b/scripts/subscribe-events.js
iindex 9b7b2c5b..6c3f46c1 100644
---- a/scripts/subscribe-events.js
++++ b/scripts/subscribe-events.js
@@@ -12,8 +12,7 @@
*
* --topic limits the run to one of TOPICS (repeatable), e.g. to add live.stream.started to a host that
* already has the other two, or `--topic tools.usage.recorded --topic events.usage.recorded` for the
-- * project dashboards (WS-N task 4). live.stream.started reads the followers from Network's own user_follows
-- * (run npm run follows-import first on a host whose Live follows were never imported).
++ * project dashboards (WS-N task 4). live.stream.started reads Network's current follow graph.
*
* Reads the environment: OV_EVENTS_INTERNAL_URL (Events, e.g. http://127.0.0.1:4300), OV_NETWORK_URL
* (the token issuer), JWT_PRIVATE_KEY (default data/keys/private.pem) and NETWORK_EVENTS_SECRET — the
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex d7255dda..1fbdf82f 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -632,7 +632,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const target = await db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(userId);
if (!target) return res.status(404).json({ ok: false, error: 'User not found' });
-- // users.email is UNIQUE, so a collision would throw a raw SQLite error —
++ // users.email is UNIQUE, so a collision would throw a raw database error —
// check first and return something the admin can actually act on.
if (email) {
const clash = await db.prepare('SELECT id, username FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(email, userId);
ddiff --git a/server/auth/revocation.js b/server/auth/revocation.js
iindex 9ff7fe28..251ae014 100644
---- a/server/auth/revocation.js
++++ b/server/auth/revocation.js
@@@ -38,7 +38,7 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {
}
/**
-- * @param {object} db better-sqlite3 database
++ * @param {object} db service database
* @param {number} userId users.id
* @param {{ reason: string, actor?: object, ctx?: object }} opts actor defaults to the person themself
* @returns {{ validAfter: string, event: object|null }} event is null for an account with no subject
ddiff --git a/server/coins/wallet.js b/server/coins/wallet.js
iindex a186ad03..a18548b1 100644
---- a/server/coins/wallet.js
++++ b/server/coins/wallet.js
@@@ -2,7 +2,7 @@
// ═══════════════════════════════════════════════════════════════
// openvibe.network — OpenCoins Wallet Core
--// Atomic wallet operations backed by better-sqlite3 transactions.
++// Atomic wallet operations backed by database transactions.
// user_id here is ALWAYS the Network (SSO) user id.
//
// Idempotency: every mutation carries an idempotency_key stored on
ddiff --git a/server/config.js b/server/config.js
iindex 7017dad9..b05b786d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -63,8 +63,7 @@ module.exports = {
// Database (ADR-035, plan T2): PostgreSQL through PgBouncer in production. `url` (DATABASE_URL) serves
// requests; `directUrl` (DATABASE_DIRECT_URL, the owner role on a direct connection) runs migrations.
// Without DATABASE_URL, development and tests run an embedded PGlite database in `pgliteDir`
-- // (PGLITE_DIR overrides it); production refuses to boot. There is no SQLite file: DB_PATH is read only
-- // by the one-time import tools (scripts/migrate-to-postgres.js, scripts/pg-preflight-collisions.js).
++ // (PGLITE_DIR overrides it); production refuses to boot.
db: {
url: process.env.DATABASE_URL || '',
directUrl: process.env.DATABASE_DIRECT_URL || '',
ddiff --git a/server/db/database.js b/server/db/database.js
iindex fb14aad3..df4645c7 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -5,8 +5,7 @@
//
// The schema lives in migrations/NNNN_*.sql; this module opens the process-wide handle, runs the
// migrations as the owner (DATABASE_DIRECT_URL) and seeds the boot data that is not schema
--// (OAuth clients, site settings, built-in themes). Every query is async; the handle is shaped like
--// better-sqlite3's (db.prepare(sql).get/all/run) but must be awaited.
++// (OAuth clients, site settings, built-in themes). Every query is async.
// ═══════════════════════════════════════════════════════════════
const path = require('path');
@@@ -315,7 +314,7 @@ function attachHelpers(db) {
if (db._ovHelpers) return db;
const secrets = require('../secrets');
-- // better-sqlite3's db.transaction(fn) returned a function; db.tx(fn) runs now. txFn keeps that
++ // db.tx(fn) runs a transaction now. txFn keeps that
// shape (a runner the caller invokes, possibly more than once, e.g. a dry-run path).
db.txFn = (fn) => async (...args) => await db.tx(() => fn(...args));
ddiff --git a/server/developer/usage.js b/server/developer/usage.js
iindex c1875df6..6e1a2583 100644
---- a/server/developer/usage.js
++++ b/server/developer/usage.js
@@@ -56,7 +56,7 @@ function mergeCodes(into, codes) {
}
/**
-- * @param {import('better-sqlite3').Database} db
++ * @param {object} db service database handle
* @param {{ now?: () => number, log?: object }} [o]
*/
function createProjectUsage(db, { now = () => Date.now(), log = console } = {}) {
ddiff --git a/server/identity/follows.js b/server/identity/follows.js
iindex b4ddffec..f7286b71 100644
---- a/server/identity/follows.js
++++ b/server/identity/follows.js
@@@ -23,9 +23,7 @@ const cache = require('openvibe-shared/cache-policy');
* DELETE /api/v1/me/follows/:type/:target unfollow (200, idempotent)
*
* :target is a usr_ subject or a current username. Nobody follows themselves; guests do not follow.
-- * Lists are never public (counts are). ADR-030's migration: importFollows() backfills Live's follows
-- * (scripts/follows-import.js, npm run follows-import), holding any pair whose side has no subject in
-- * follow_import_holds.
++ * Lists are never public (counts are).
*/
const express = require('express');
const { ids, validate, http } = require('openvibe-contracts');
@@@ -38,7 +36,7 @@ const MAX_FOLLOWING = 5000;
// A follow that starts tells the followed person (a FOLLOW notification, server/notifications/follow-notify.js),
// set at boot. It runs inside setFollow's transaction, so the follow, its event and the notification commit
--// together; a notifier that throws never fails the follow. Imports (emit: false) and flag changes notify nobody.
++// together; a notifier that throws never fails the follow. Flag changes notify nobody.
let notifier = null;
function setNotifier(fn) { notifier = typeof fn === 'function' ? fn : null; }
@@@ -173,59 +171,6 @@ async function onSubjectRemoved(db, subject) {
return rows.length;
}
--/**
-- * ADR-030 step 2: import follows from another system's rows, mapped to subjects by `subjectOf`.
-- * rows: [{ follower_ref, target_ref, notify_email, notify_push, created_at }] (the refs are that system's ids).
-- * A pair whose side has no subject (or is the same person) is held in follow_import_holds, never dropped.
-- * A pair Network already has, followed or unfollowed since, is Network's decision and is left alone, so a
-- * re-run never brings back an unfollow. No events (the source system already has these follows); dryRun
-- * changes nothing.
-- * Reconciliation is an explicit, final cutover step while Live follow writes are paused: deactivate
-- * Live-sourced active pairs absent from its snapshot, leaving Network-sourced pairs and tombstones alone.
-- * → { imported, unchanged, deactivated, held: [{ follower_ref, target_ref, reason }] }
-- */
--async function importFollows(db, source, rows, subjectOf, { dryRun = false, reconcile = false, now = new Date().toISOString() } = {}) {
-- const out = { imported: 0, unchanged: 0, deactivated: 0, held: [] };
-- const run = async () => {
-- const present = new Set();
-- for (const r of rows) {
-- const follower = subjectOf(r.follower_ref);
-- const target = subjectOf(r.target_ref);
-- let reason = null;
-- if (!follower) reason = 'follower_has_no_subject';
-- else if (!target) reason = 'target_has_no_subject';
-- else if (follower === target) reason = 'self_follow';
-- if (reason) {
-- out.held.push({ follower_ref: String(r.follower_ref), target_ref: String(r.target_ref), reason });
-- if (!dryRun) await db.prepare('INSERT INTO follow_import_holds (source, follower_ref, target_ref, reason, seen_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (source, follower_ref, target_ref) DO UPDATE SET reason = excluded.reason, seen_at = excluded.seen_at').run(source, String(r.follower_ref), String(r.target_ref), reason, now);
-- continue;
-- }
-- present.add(`${follower}:${target}`);
-- // Both sides have subjects now: any earlier hold of this pair is resolved.
-- if (!dryRun) await db.prepare('DELETE FROM follow_import_holds WHERE source = ? AND follower_ref = ? AND target_ref = ?').run(source, String(r.follower_ref), String(r.target_ref));
-- const prev = await db.prepare('SELECT active FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, 'channel', target);
-- if (prev) { out.unchanged++; continue; }
-- if (!dryRun) {
-- await setFollow(db, follower, 'channel', target, true, {
-- notifyEmail: r.notify_email == null ? true : !!r.notify_email, notifyPush: r.notify_push == null ? true : !!r.notify_push,
-- emit: false, source, at: r.created_at ? new Date(r.created_at).toISOString() : now,
-- });
-- }
-- out.imported++;
-- }
-- if (reconcile) {
-- const active = await db.prepare("SELECT follower_subject, target_id FROM user_follows WHERE target_type = 'channel' AND source = ? AND active = 1").all(source);
-- for (const row of active) {
-- if (present.has(`${row.follower_subject}:${row.target_id}`)) continue;
-- if (!dryRun) await setFollow(db, row.follower_subject, 'channel', row.target_id, false, { emit: false, source, at: now });
-- out.deactivated++;
-- }
-- }
-- };
-- if (dryRun) await run(); else await db.tx(run);
-- return out;
--}
--
/** The signed-in person as a follower, or a FollowError. */
async function meOf(db, user) {
if (!user || user.is_anon) throw new FollowError(403, 'follows.guest', 'sign in with an account to follow');
@@@ -377,4 +322,4 @@ async function onSubjectMerged(db, { from, into }) {
return out;
}
--module.exports = { setNotifier, onSubjectMerged, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };
++module.exports = { setNotifier, onSubjectMerged, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, routers, kick };
ddiff --git a/server/identity/profile-events.js b/server/identity/profile-events.js
iindex c9f8c9b4..e203be4f 100644
---- a/server/identity/profile-events.js
++++ b/server/identity/profile-events.js
@@@ -3,7 +3,7 @@
* network.user.updated (Contracts 0.43.0; roadmap WS-B task 2): a person's whole current profile after every
* change other services may know about: username, display name, picture, colour, role or ban.
*
-- * No write path has to remember it. SQLite triggers on `users` record each change in user_profile_changes
++ * No write path has to remember it. PostgreSQL triggers on `users` record each change in user_profile_changes
* within the change's own transaction, so a rename, a role grant, a ban or a profile edit from anywhere
* (admin routes, the API, an import, a hand-run SQL) is never missed. drain() turns those rows into events:
* per person, it raises users.profile_revision by one, builds the payload from the row as it is now, puts
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex b796065d..01d7a3c7 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -123,7 +123,7 @@ router.get('/url-registry/resolved', principals.guard('network.registry.read'),
// ═══════════════════════════════════════════════════════════════
// OpenCoins Wallet (server-to-server)
--// Atomic better-sqlite3 transactions with idempotency_key dedupe.
++// Atomic database transactions with idempotency_key dedupe.
// Repeating a key returns the original result (no double-apply).
// user_id is ALWAYS the Network (SSO) user id.
// ═══════════════════════════════════════════════════════════════
ddiff --git a/server/notifications/events-consumer.js b/server/notifications/events-consumer.js
iindex 4027be23..370e152b 100644
---- a/server/notifications/events-consumer.js
++++ b/server/notifications/events-consumer.js
@@@ -154,7 +154,7 @@ function liveStarted(event, { now, maxAgeMs }) {
/**
* @param {object} o
-- * @param {import('better-sqlite3').Database} o.db
++ * @param {object} o.db service database handle
* @param {{ create(data: object): object|null }} o.notifications NotificationService
* @param {string|string[]} o.secrets NETWORK_EVENTS_SECRET (comma list) or an array
* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]
ddiff --git a/server/push/push-service.js b/server/push/push-service.js
iindex 585da5ad..4b9b819d 100644
---- a/server/push/push-service.js
++++ b/server/push/push-service.js
@@@ -33,7 +33,7 @@ try {
/**
* Initialize VAPID keys. Generates a new keypair on first run and stores in site_settings.
-- * @param {object} db - better-sqlite3 Database instance
++ * @param {object} db - service database handle
*/
function publicFromPrivate(privateKey) {
const ecdh = require('crypto').createECDH('prime256v1');
ddiff --git a/server/reset-db.js b/server/reset-db.js
iindex 65dce456..34209b35 100644
---- a/server/reset-db.js
++++ b/server/reset-db.js
@@@ -12,7 +12,7 @@ const config = require('./config');
async function main({ log = console, cfg = config } = {}) {
if (cfg.db.url) {
log.error('reset-db: the database is PostgreSQL (DATABASE_URL is set); there is no file to remove.');
-- log.error("Drop and recreate the schema as the owner instead, e.g. psql \"$DATABASE_DIRECT_URL\" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' — or point DATABASE_URL at a scratch database and re-run scripts/migrate-to-postgres.js.");
++ log.error("Drop and recreate the schema as the owner instead, e.g. psql \"$DATABASE_DIRECT_URL\" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' — or point DATABASE_URL at a scratch database.");
return 1;
}
ddiff --git a/test/data-ownership.test.js b/test/data-ownership.test.js
iindex be500214..27923e20 100644
---- a/test/data-ownership.test.js
++++ b/test/data-ownership.test.js
@@@ -4,13 +4,9 @@
// node test/data-ownership.test.js
const assert = require('assert');
const fs = require('fs');
--const os = require('os');
const path = require('path');
const { getDb, initDb } = require('../server/db/database');
--const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-data-ownership-'));
--process.env.OPENVIBELIVE_DB_PATH = path.join(dir, 'live.db');
--
(async () => {
const db = await getDb();
await db.prepare("INSERT INTO users (username, password_hash, role, legacy_source, legacy_id) VALUES ('migrated', 'x', 'user', 'live', 7)").run();
@@@ -20,7 +16,7 @@ await initDb();
assert.strictEqual((await db.prepare("SELECT role FROM users WHERE username = 'migrated'").get()).role, 'user',
'a boot must not copy a role out of Live\'s database');
--// No server code names Live's database or the variable that pointed at it.
++// No server code opens Live's database file.
function walk(d, out = []) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
@@@ -29,9 +25,8 @@ function walk(d, out = []) {
return out;
}
const offenders = walk(path.join(__dirname, '..', 'server'))
-- .filter((f) => /OPENVIBELIVE_DB_PATH|live\.db\b/.test(fs.readFileSync(f, 'utf8')));
++ .filter((f) => /live\.db\b/.test(fs.readFileSync(f, 'utf8')));
assert.deepStrictEqual(offenders, [], 'server code must not reference Live\'s database');
--fs.rmSync(dir, { recursive: true, force: true });
console.log('data-ownership: ok');
})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/deploy-wrapper.test.js b/test/deploy-wrapper.test.js
iindex c1bef53a..7aed6ae2 100644
---- a/test/deploy-wrapper.test.js
++++ b/test/deploy-wrapper.test.js
@@@ -1,8 +1,7 @@
'use strict';
/**
* deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy network --install-units` (OpenVibe.Host,
-- * strategy git-checkout; roadmap WS-N task 11) with deploy-legacy.sh (the previous script, unchanged) as
-- * its fallback. A fake ovhost records what the wrapper asks for; a fake legacy script records the fallback.
++ * strategy git-checkout). A fake ovhost records what the wrapper asks for.
*/
const assert = require('assert');
const fs = require('fs');
@@@ -15,26 +14,19 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'network-deploy-wrapper-'));
const log = path.join(tmp, 'calls.log');
const ovhost = path.join(tmp, 'ovhost');
fs.writeFileSync(ovhost, `#!/usr/bin/env bash
--if [ "$1" = capabilities ]; then
-- [ -n "$FAKE_OLD" ] && exit 1
-- printf '%b\\n' "\${FAKE_CAPS:-ovhost=0.3.0\\ndeploy-api=1\\nservice=network\\nstrategy=git-checkout\\nmanaged=yes\\nlayout=git}"
-- exit 0
--fi
echo "ovhost $*" >> "${log}"
exit "\${FAKE_EXIT:-0}"
`, { mode: 0o755 });
--const legacy = path.join(tmp, 'legacy.sh');
--fs.writeFileSync(legacy, `#!/usr/bin/env bash\necho "legacy" >> "${log}"\n`, { mode: 0o755 });
function 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 };
}
--// The unit file is installed on every deploy, as the old script did.
++// The unit file is installed on every deploy.
assert.deepStrictEqual(run().calls, ['ovhost deploy network --install-units']);
assert.deepStrictEqual(run(['--restart']).calls, ['ovhost deploy network --restart --install-units']);
assert.deepStrictEqual(run(['--rollback']).calls, ['ovhost rollback network']);
@@@ -42,16 +34,11 @@ assert.deepStrictEqual(run([], { DRY_RUN: '1' }).calls, ['ovhost plan network'])
assert.strictEqual(run([], { FAKE_EXIT: '3' }).code, 3, "ovhost's exit code is the wrapper's");
assert.strictEqual(run(['--nope']).code, 1);
--let r = run([], { FAKE_OLD: '1' });
--assert.deepStrictEqual(r.calls, ['legacy']);
--assert.match(r.out, /too old/);
--r = run([], { FAKE_CAPS: 'deploy-api=1\\nstrategy=git-checkout\\nmanaged=no' });
--assert.deepStrictEqual(r.calls, ['legacy']);
--assert.match(r.out, /does not manage network/);
--assert.deepStrictEqual(run([], { OVHOST: path.join(tmp, 'missing') }).calls, ['legacy']);
--assert.strictEqual(run(['--rollback'], { OVHOST_LEGACY: '1' }).code, 1, 'the legacy script has no rollback');
--const legacyText = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');
--assert.match(legacyText, /git pull "\$GIT_REMOTE" "\$GIT_BRANCH" --ff-only/, 'deploy-legacy.sh is the previous script');
++const missing = run([], { OVHOST: path.join(tmp, 'missing') });
++assert.strictEqual(missing.code, 1);
++assert.deepStrictEqual(missing.calls, []);
++assert.match(missing.out, /ovhost not found/);
++assert.doesNotMatch(fs.readFileSync(WRAPPER, 'utf8'), /deploy-legacy|DEPLOY_LEGACY|OVHOST_LEGACY|capabilities/);
assert.match(fs.readFileSync(WRAPPER, 'utf8'), /^set -euo pipefail$/m);
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/follow-notify.test.js b/test/follow-notify.test.js
iindex cbab3592..3b3290c7 100644
---- a/test/follow-notify.test.js
++++ b/test/follow-notify.test.js
@@@ -63,9 +63,9 @@ n = await followsOf(2);
assert.strictEqual(n.length, 2);
assert.strictEqual(n[1].message, 'cat followed you');
--// An import (Live's backfill) announces nothing and notifies nobody.
--await follows.importFollows(db, 'live', [{ follower_ref: 4, target_ref: 1 }], (ref) => ({ 4: DAN, 1: ANN })[ref]);
--assert.ok((await follows.status(db, 'channel', ANN, DAN)).following, 'imported');
++// A follow written without event emission also creates no notification.
++await follows.setFollow(db, DAN, 'channel', ANN, true, { emit: false });
++assert.ok((await follows.status(db, 'channel', ANN, DAN)).following);
assert.strictEqual((await followsOf(1)).length, 0);
// The followed person's social preference off: the follow is made, no notification.
ddiff --git a/test/follows-import.test.js b/test/follows-import.test.js
ddeleted file mode 100644
iindex 23f418d0..00000000
---- a/test/follows-import.test.js
++++ /dev/null
@@@ -1,117 +0,0 @@
--'use strict';
--// The follows backfill and its preflight (plan T2 "Follows"): npm run follows-import reads Live's follows from a
--// read-only SQLite file and imports them into user_follows (dry run by default, no events, pairs without a
--// subject held), a re-run imports nothing and never brings back an unfollow, and a hold is cleared once its pair
--// maps; npm run follows-preflight prints counts only.
--// node test/follows-import.test.js
--const assert = require('assert');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const { ids } = require('openvibe-contracts');
--const { getDb } = require('../server/db/database');
--const importer = require('../scripts/follows-import');
--const preflight = require('../scripts/follows-preflight');
--
--(async () => {
--const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follows-import-'));
--const log = console.log; console.log = () => {};
--const db = getDb();
--console.log = log;
--try {
-- const ANN = ids.newId('user'), BOB = ids.newId('user'), CAT = ids.newId('user'), DAN = ids.newId('user');
-- await db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', ?), (2, 'bob', 'x', ?), (3, 'cat', 'x', ?), (4, 'dan', 'x', ?)").run(ANN, BOB, CAT, DAN);
--
-- // Live's database: users 11..14 are ann, bob, cat and dan; 15 has no Network subject yet.
-- const liveFile = path.join(dir, 'live.db');
-- const live = importer.openSqlite(liveFile, { readonly: false });
-- live.exec(`CREATE TABLE follows (id INTEGER PRIMARY KEY AUTOINCREMENT, follower_id INTEGER NOT NULL, streamer_id INTEGER NOT NULL,
-- email_notify INTEGER DEFAULT 0, push_notify INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(follower_id, streamer_id));
-- CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, service TEXT NOT NULL, service_user_id TEXT NOT NULL, subject_id TEXT);`);
-- const link = live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', ?, ?)");
-- [[11, ANN], [12, BOB], [13, CAT], [14, DAN]].forEach(([u, s], i) => link.run(u, String(i + 1), s));
-- const liveFollow = live.prepare("INSERT INTO follows (follower_id, streamer_id, email_notify, push_notify, created_at) VALUES (?, ?, ?, 1, '2026-01-02 03:04:05')");
-- liveFollow.run(12, 11, 1); // bob → ann
-- liveFollow.run(13, 11, 0); // cat → ann
-- liveFollow.run(14, 11, 0); // dan → ann: unfollowed on Network since
-- liveFollow.run(15, 11, 0); // no subject: held
-- liveFollow.run(13, 12, 0); // cat → bob: already on Network
-- live.close();
-- await db.prepare("INSERT INTO user_follows (follower_subject, target_type, target_id, active) VALUES (?, 'channel', ?, 0), (?, 'channel', ?, 1)").run(DAN, ANN, CAT, BOB);
--
-- const lines = [];
-- const run = async (fn, argv) => { lines.length = 0; const code = await fn.main(argv, { db, log: (m) => lines.push(m) }); return { code, out: lines.join('\n') }; };
-- const active = async () => (await db.prepare("SELECT follower_subject AS f, target_id AS t, notify_email AS e, source FROM user_follows WHERE active = 1 ORDER BY follower_subject, target_id").all());
--
-- assert.strictEqual((await run(importer, [])).code, 2, 'the Live database is required');
-- assert.strictEqual((await run(importer, ['--live-db', path.join(dir, 'nope.db')])).code, 2);
--
-- // Dry run: counts, nothing changed.
-- let r = await run(importer, ['--live-db', liveFile]);
-- assert.strictEqual(r.code, 0, r.out);
-- assert.match(r.out, /live rows {2}5; to import 2; already on Network 2; to deactivate 0; held: follower_has_no_subject 1/);
-- assert.match(r.out, /dry run: nothing changed/);
-- assert.strictEqual((await active()).length, 1);
-- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS c FROM follow_import_holds').get()).c, 0);
--
-- // Preflight before: counts only.
-- r = await run(preflight, []);
-- assert.strictEqual(r.code, 0, r.out);
-- assert.match(r.out, /active follows {5}1 \(1 channels, 1 followers\)\nunfollowed rows {4}1\nunresolved holds {3}0$/);
--
-- // Applied: bob and cat now follow ann (Live's flags and time kept, source live), dan's unfollow stands.
-- r = await run(importer, ['--live-db', liveFile, '--apply']);
-- assert.match(r.out, /imported 2; already on Network 2; deactivated 0; held: follower_has_no_subject 1/);
-- const rows = await active();
-- assert.deepStrictEqual(rows.filter((x) => x.t === ANN).map((x) => [x.f, x.e, x.source]).sort(), [[BOB, 1, 'live'], [CAT, 0, 'live']].sort());
-- assert.ok(!rows.some((x) => x.f === DAN), 'an unfollow made on Network is never brought back');
-- assert.strictEqual((await db.prepare('SELECT created_at FROM user_follows WHERE follower_subject = ? AND target_id = ?').get(BOB, ANN)).created_at, '2026-01-02T03:04:05.000Z');
-- assert.strictEqual((await db.prepare("SELECT COUNT(*) AS c FROM network_event_outbox WHERE envelope->>'event_type' LIKE 'network.follow.%'").get()).c, 0, 'no events');
-- assert.strictEqual((await db.prepare("SELECT COUNT(*) AS c FROM notifications WHERE type = 'FOLLOW'").get()).c, 0, 'nobody is notified');
--
-- // Preflight: the held pair is counted, never named.
-- r = await run(preflight, []);
-- assert.match(r.out, /active follows {5}3 \(2 channels, 2 followers\)\nunfollowed rows {4}1\nunresolved holds {3}1 \(live:follower_has_no_subject 1\)$/);
-- for (const s of [ANN, BOB, CAT, DAN]) assert.ok(!r.out.includes(s), `no personal data: ${s}`);
-- assert.doesNotMatch(r.out, /\b(ann|bob|cat|dan)\b|usr_/, 'no usernames or subjects');
--
-- // Idempotent: a second run imports nothing.
-- r = await run(importer, ['--live-db', liveFile, '--apply']);
-- assert.match(r.out, /imported 0; already on Network 4; deactivated 0; held: follower_has_no_subject 1/);
-- assert.strictEqual((await active()).length, 3);
--
-- // Live user 15 gets a Network account: the next run imports the pair and clears its hold.
-- const EVE = ids.newId('user');
-- await db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (5, 'eve', 'x', ?)").run(EVE);
-- const live2 = importer.openSqlite(liveFile, { readonly: false });
-- live2.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (15, 'network', '5', ?)").run(EVE);
-- live2.close();
-- r = await run(importer, ['--live-db', liveFile, '--apply']);
-- assert.match(r.out, /imported 1; already on Network 4; deactivated 0; held: none/);
-- r = await run(preflight, []);
-- assert.match(r.out, /unresolved holds {3}0$/);
-- // Live unfollowed cat after the import. Ordinary import preserves Network state;
-- // final reconciliation removes the stale Live-sourced follow but keeps Network's own pair.
-- const live3 = importer.openSqlite(liveFile, { readonly: false });
-- live3.prepare('DELETE FROM follows WHERE follower_id = 13 AND streamer_id = 11').run();
-- live3.close();
-- await db.prepare("INSERT INTO user_follows (follower_subject, target_type, target_id, active, source) VALUES (?, 'channel', ?, 1, 'network')").run(BOB, DAN);
-- r = await run(importer, ['--live-db', liveFile, '--apply']);
-- assert.match(r.out, /deactivated 0/);
-- assert.strictEqual((await db.prepare('SELECT active FROM user_follows WHERE follower_subject = ? AND target_id = ?').get(CAT, ANN)).active, 1);
-- r = await run(importer, ['--live-db', liveFile, '--reconcile']);
-- assert.match(r.out, /to deactivate 1/);
-- assert.strictEqual((await db.prepare('SELECT active FROM user_follows WHERE follower_subject = ? AND target_id = ?').get(CAT, ANN)).active, 1, 'preview changes nothing');
-- r = await run(importer, ['--live-db', liveFile, '--reconcile', '--apply']);
-- assert.match(r.out, /deactivated 1/);
-- assert.strictEqual((await db.prepare('SELECT active FROM user_follows WHERE follower_subject = ? AND target_id = ?').get(CAT, ANN)).active, 0);
-- assert.strictEqual((await db.prepare('SELECT active FROM user_follows WHERE follower_subject = ? AND target_id = ?').get(BOB, DAN)).active, 1);
-- assert.strictEqual((await db.prepare("SELECT COUNT(*) AS c FROM network_event_outbox WHERE envelope->>'event_type' LIKE 'network.follow.%'").get()).c, 0, 'reconciliation emits no follow events');
-- r = await run(importer, ['--live-db', liveFile, '--reconcile', '--apply']);
-- assert.match(r.out, /deactivated 0/, 'reconciliation is idempotent');
-- assert.strictEqual((await run(preflight, ['--apply'])).code, 2, 'the preflight takes no options');
--} finally {
-- fs.rmSync(dir, { recursive: true, force: true });
--}
--console.log('follows import: all checks passed');
--})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/follows.test.js b/test/follows.test.js
iindex 076f9eb5..7f70fcdd 100644
---- a/test/follows.test.js
++++ b/test/follows.test.js
@@@ -122,9 +122,6 @@ const valid = (e) => {
assert.strictEqual(await db.tx(async () => await follows.onSubjectRemoved(db, CAT)), 1);
assert.deepStrictEqual((await events()).pop().payload.reason, 'account_removed');
-- // The Live backfill and its reconciliation used to be exercised here through the one-time
-- // scripts/follows-backfill.js (retired in plan T2); importFollows() itself is covered by
-- // test/follow-notify.test.js.
} finally {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
ddiff --git a/test/notification-events.test.js b/test/notification-events.test.js
iindex 302a21fb..01fadd27 100644
---- a/test/notification-events.test.js
++++ b/test/notification-events.test.js
@@@ -10,9 +10,6 @@
// notifies nobody again.
// node test/notification-events.test.js
const assert = require('assert');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
const http = require('http');
const crypto = require('crypto');
const express = require('express');
@@@ -31,8 +28,6 @@ const { signToken } = require('../server/auth/routes');
const ISSUER = 'https://openvibe.network';
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
--const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-notification-events-'));
--const DB_PATH = path.join(dir, 'network.db');
const quietly = (f) => { const l = console.log; console.log = () => {}; try { return f(); } finally { console.log = l; } };
let db = getDb();
@@@ -247,7 +242,6 @@ const checkEnvelope = (env, subject) => {
assert.deepStrictEqual(discord, ['carol']);
await stop(proc);
-- fs.rmSync(dir, { recursive: true, force: true });
console.log('notification events: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/pg-cutover-rehearsal.test.js b/test/pg-cutover-rehearsal.test.js
ddeleted file mode 100644
iindex 6cd936f5..00000000
---- a/test/pg-cutover-rehearsal.test.js
++++ /dev/null
@@@ -1,156 +0,0 @@
--'use strict';
--// The T2 cutover rehearsal (scripts/rehearse-pg-cutover.js, docs/cutover-t2-postgres.md) on its seeded fixture:
--// commit 20dd7ff's SQLite schema with representative rows → pg-preflight → migrate-to-postgres → row-count
--// parity → the server booted on the result with /api/ready green. On PGlite by default, on a scratch database
--// in the containers under npm run test:pg. A case-fold collision stops it at the preflight, and the parity
--// check reports a target that lacks the rows or whose users_profile_created trigger the import left off.
--// node test/pg-cutover-rehearsal.test.js
--const assert = require('assert');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const { spawn } = require('child_process');
--const { rowParity } = require('../scripts/pg-row-parity');
--
--const SCRIPT = path.join(__dirname, '..', 'scripts', 'rehearse-pg-cutover.js');
--// Never a real target: the default store ignores DATABASE_URL, and the child runs without it anyway.
--const env = { ...process.env };
--delete env.DATABASE_URL;
--delete env.DATABASE_DIRECT_URL;
--delete env.NODE_OPTIONS;
--
--function rehearse(args, extraEnv = {}) {
-- return new Promise((resolve) => {
-- const p = spawn(process.execPath, [SCRIPT, ...args], { env: { ...env, ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'] });
-- let stdout = '', stderr = '';
-- p.stdout.on('data', (d) => { stdout += d; });
-- p.stderr.on('data', (d) => { stderr += d; });
-- p.on('close', (code) => {
-- const lines = stdout.trim().split('\n');
-- let json = null;
-- try { json = JSON.parse(lines[lines.length - 1]); } catch { /* asserted below */ }
-- resolve({ code, json, lines, stderr });
-- });
-- });
--}
--
--(async () => {
-- // 1. The seeded fixture, end to end.
-- const r = await rehearse([]);
-- assert.ok(r.json, `one JSON line last on stdout:\n${r.lines.join('\n')}\n${r.stderr}`);
-- assert.strictEqual(r.lines.length, 1, 'stdout is the JSON line alone');
-- assert.strictEqual(r.json.ok, true, `rehearsal ok: ${JSON.stringify(r.json.problems)}\n${r.stderr}`);
-- assert.strictEqual(r.code, 0);
-- assert.strictEqual(r.json.source, 'seeded-20dd7ff');
-- assert.strictEqual(r.json.store, process.env.OV_TEST_PG_DIRECT_URL ? 'postgresql' : 'pglite');
-- assert.deepStrictEqual(r.json.preflight, { ok: true, exit: 0, collisions: [] });
-- const expected = { users: 6, oauth_clients: 2, oauth_codes: 2, oauth_tokens: 3, notifications: 5, notification_preferences: 3, user_sessions: 2,
-- wallets: 3, coin_transactions: 6, follows: 3, user_follows: 3, user_blocks: 2, verification_keys: 2, network_event_outbox: 1, themes: 0,
-- // 20dd7ff's trigger wrote one per non-anonymous account; PostgreSQL's must not add its own during the import.
-- user_profile_changes: 5 };
-- for (const [t, n] of Object.entries(expected)) assert.deepStrictEqual(r.json.tables[t], { sqlite: n, pg: n }, `${t}: ${n} rows on both sides`);
-- for (const [t, c] of Object.entries(r.json.tables)) assert.strictEqual(c.sqlite, c.pg, `${t}: parity`);
-- // 77 tables at 20dd7ff (initDb's and those its modules create at boot), less the one left behind.
-- assert.strictEqual(Object.keys(r.json.tables).length, 76, 'every table of the 20dd7ff schema is compared');
-- assert.ok(!('analytics_rate_tracking' in r.json.tables) && !('analytics_rate_tracking' in r.json.pg_only), 'per-IP counters are never carried over');
-- assert.deepStrictEqual(r.json.import.cleaned, [{ column: 'notifications.message', values: 1 }], 'the NUL PostgreSQL refuses is dropped and reported');
-- assert.strictEqual(r.json.ready.ok, true);
-- assert.strictEqual(r.json.ready.http, 200);
-- assert.strictEqual(r.json.ready.db, 'ok');
-- assert.strictEqual(r.json.ready.signing_key, 'ok');
-- assert.strictEqual(r.json.ready.node_env, process.env.OV_TEST_PG_DIRECT_URL ? 'production' : 'test');
-- assert.ok(!/\$2a\$10\$|rehearsal-client-secret|rt-family/.test(r.lines[0]), 'counts only: no row value in the output');
--
-- // 2. A case-fold collision (alex / ALEX) stops the cutover at the preflight, before any import.
-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-rehearsal-test-'));
-- const file = path.join(dir, 'collide.db');
-- const w = await rehearse(['--write-fixture', file]);
-- assert.strictEqual(w.code, 0, w.stderr);
-- const Database = require('better-sqlite3');
-- const db = new Database(file);
-- db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (7, 'ALEX', 'x', 'usr_rehearsal0007')").run();
-- db.close();
-- const c = await rehearse(['--sqlite', file, '--store', 'pglite']);
-- assert.strictEqual(c.code, 1);
-- assert.strictEqual(c.json.ok, false);
-- assert.strictEqual(c.json.source, 'snapshot');
-- assert.strictEqual(c.json.preflight.ok, false);
-- assert.deepStrictEqual(c.json.preflight.collisions, [{ table: 'users', column: 'username', spellings: 2 }]);
-- assert.strictEqual(c.json.import, null, 'nothing imported');
-- assert.strictEqual(c.json.ready, null, 'nothing booted');
-- assert.ok(fs.existsSync(file), 'the --sqlite file is left in place');
--
-- // 3. Parity against a database that lacks the rows (this process's migrated, empty one) is a mismatch per table.
-- const src = new Database(file, { readonly: true });
-- const report = await rowParity(src, globalThis.__ovNetworkTestDb);
-- src.close();
-- assert.strictEqual(report.ok, false);
-- assert.deepStrictEqual(report.tables.users, { sqlite: 7, pg: 0 });
-- assert.ok(report.problems.some((p) => p.table === 'coin_transactions' && /SQLite 6, PostgreSQL 0/.test(p.problem)));
-- assert.ok(!report.problems.some((p) => p.table === 'dev_projects'), 'equal counts (0 and 0) are not problems');
-- assert.ok(!report.problems.some((p) => /trigger/.test(p.problem)), 'users_profile_created is enabled');
--
-- // 4. An import killed between its ALTERs leaves users_profile_created off: parity refuses that database. On a
-- // PGlite of its own: the test database's role (DML only under test:pg, as production's runtime) cannot ALTER.
-- const { createDb } = require('openvibe-sdk/db');
-- const { MIGRATIONS } = require('../scripts/migrate-to-postgres');
-- const owner = createDb({ pglite: true, service: 'network-rehearsal-test', log: { log() {}, warn() {}, error: console.error } });
-- let off;
-- try {
-- await owner.migrate({ dir: MIGRATIONS, log: { log() {}, warn() {}, error: console.error } });
-- await owner.exec('ALTER TABLE users DISABLE TRIGGER users_profile_created');
-- const src2 = new Database(file, { readonly: true });
-- try { off = await rowParity(src2, owner); } finally { src2.close(); }
-- } finally { await owner.close(); }
-- assert.ok(off.problems.some((p) => p.table === 'users' && /trigger users_profile_created is disabled/.test(p.problem)), JSON.stringify(off.problems));
-- fs.rmSync(dir, { recursive: true, force: true });
--
-- // 5. --store env refuses an unset or empty DATABASE_URL before it connects anywhere; the message names no URL.
-- for (const extra of [{}, { DATABASE_URL: '' }, { DATABASE_URL: ' ' }]) {
-- const e = await rehearse(['--store', 'env'], { DATABASE_DIRECT_URL: 'postgres://[email protected]:9/none', ...extra });
-- assert.strictEqual(e.code, 1, e.stderr);
-- assert.strictEqual(e.json.ok, false);
-- assert.ok(e.json.problems.some((p) => /needs DATABASE_DIRECT_URL and DATABASE_URL.*DATABASE_URL is unset or empty/.test(p)), JSON.stringify(e.json.problems));
-- assert.strictEqual(e.json.import, null, 'nothing imported');
-- assert.ok(!/127\.0\.0\.1:9|postgres:\/\//.test(e.lines.join('\n')), 'no URL in the output');
-- }
--
-- // 6. The same-database guard: two separate databases are refused; one empty database is accepted; rows refuse it
-- // unless --truncate-target; the probe table never stays behind.
-- const { checkScratchTarget, pollReady } = require('../scripts/rehearse-pg-cutover');
-- const quietLog = { log() {}, warn() {}, error: console.error };
-- const dbA = createDb({ pglite: true, service: 'network-rehearsal-test-a', log: quietLog });
-- const dbB = createDb({ pglite: true, service: 'network-rehearsal-test-b', log: quietLog });
-- try {
-- await assert.rejects(checkScratchTarget(dbA, dbB), (e) => /does not reach the database/.test(e.message) && !/postgres:\/\//.test(e.message));
-- await checkScratchTarget(dbA, dbA);
-- const probes = `SELECT count(*)::int FROM pg_class WHERE relname LIKE 'ov_rehearsal_probe_%'`;
-- assert.strictEqual(Number(await dbA.value(probes)), 0, 'the probe is dropped');
-- assert.strictEqual(Number(await dbB.value(probes)), 0);
-- await dbA.exec('CREATE TABLE rehearsal_rows (id int)');
-- await dbA.exec('INSERT INTO rehearsal_rows VALUES (1)');
-- await assert.rejects(checkScratchTarget(dbA, dbA), /not empty through DATABASE_DIRECT_URL/);
-- await checkScratchTarget(dbA, dbA, { truncateTarget: true });
-- assert.strictEqual(Number(await dbA.value(probes)), 0);
-- } finally { await dbA.close(); await dbB.close(); }
--
-- // 7. A server that accepts and never answers (or stalls mid-body) cannot hold the readiness poll past its deadline.
-- const http = require('http');
-- for (const handler of [() => {}, (req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.write('{"ready":'); }]) {
-- const stalled = http.createServer(handler);
-- await new Promise((r) => stalled.listen(0, '127.0.0.1', r));
-- const t = Date.now();
-- try {
-- const p = await pollReady(`http://127.0.0.1:${stalled.address().port}`, { timeoutMs: 3000, requestMs: 1000 });
-- const took = Date.now() - t;
-- assert.strictEqual(p.body, null, 'no answer read');
-- // It must wait for the deadline (>= 2500), and must give up near it rather than hang. The upper bound is loose:
-- // with other test files in parallel this process can be descheduled past the timer, so wall-clock can stretch
-- // well beyond the 3 s deadline even though the poll itself honoured it.
-- assert.ok(took >= 2500 && took < 30000, `returned in ${took} ms (deadline 3000)`);
-- } finally { stalled.closeAllConnections(); await new Promise((r) => stalled.close(r)); }
-- }
--
-- console.log(`pg cutover rehearsal: all checks passed (${r.json.store}, ${Object.keys(r.json.tables).length} tables, ready ${r.json.ready.status})`);
-- process.exit(0);
--})().catch((err) => { console.error(err); process.exit(1); });