openvibers/openrestream Commits

OpenRestream: the slot-cutover importer and preflight read Live's PostgreSQL (read-only), not a SQLite snapshot (#37)

OP OpenVibers committed 10 Oct 2026 · 8046447ef3 · 9 files, +202 −524

Plan T4 (OpenRestream owns all ingest; "cut all slots over"). The slot-cutover tooling still read a **SQLite snapshot** of Live's database (`sqlite3 live.db .backup`), but Live moved to PostgreSQL (ov_live) on 2026-10-08 and its SQLite file is frozen, so the cutover could not run against current data. Now it reads Live's PostgreSQL directly, read-only.

- **`server/migrate-live.js`:** reads columns from `information_schema` (not `PRAGMA table_info`), awaits every Live read (some were SQLite-synchronous), and treats Live's 0/1 flags as either integers or booleans. A NULL `force_vod_recording_disabled` still allows recording, as before.
- **`scripts/migrate-from-live.js`:** `--live-db env:<file>` (default `env:/etc/openvibe/live.env`: that file's `DATABASE_URL`, never printed or loaded into this process's environment) or a `postgres://` URL. The import runs inside one `SET TRANSACTION READ ONLY` transaction, so a write to Live fails. The shared parsing lives in `scripts/lib/live-db.js`.
- **`scripts/cutover-preflight.js`:** Live's database defaults to `env:/etc/openvibe/live.env`. A file path is refused with an explanation, and the dead SQLite integrity/FK branches are gone.
- **Tests:** `test/migration.test.js` and `test/preflight.test.js` build their Live and Events fixtures in PGlite. They keep every assertion and add one: a write through the read-only Live handle fails.
- **`docs/cutover.md`:** no snapshot step; the import reads production Live directly. `better-sqlite3` is gone, and the lockfile is regenerated.

`ov test`: 4 files passed (the bind-switch test skips without a shared PostgreSQL, as before). After the deploy, a production **dry run** (`node scripts/migrate-from-live.js`, no `--apply`) proves it against the real Live database. Drafted by a Codex job and reviewed here (one NULL-semantics fix).

Changes

ddiff --git a/docs/cutover.md b/docs/cutover.md
iindex ae7555c..6f041e8 100644
---- a/docs/cutover.md
++++ b/docs/cutover.md
@@@ -46,7 +46,7 @@ Everything below runs on the host. The scripts ship with the release (`scripts/`
|---|---|---|
| `cutover-preflight.js` | read-only checks: `release service env bind dns port db live-env events [slot]` (`--only`, `--skip`, `--slot <id>`, `--json`, `--strict`, `--expect-release <sha>`, `--probe-url`, `--host-ip`) | nothing |
| `subscribe-live-events.js` | Live's Events subscription `openre.session.*` → Live, **as Live** (reads `/etc/openvibe/live.env`); `--dry-run`, `--disable`, `--enable` | one subscription in Events |
--| `migrate-from-live.js` | imports slots and destinations from a Live **snapshot** (dry run unless `--apply`) | the PostgreSQL store (`ov_openre`) |
++| `migrate-from-live.js` | imports slots and destinations from Live PostgreSQL in a read-only transaction (dry run unless `--apply`) | the PostgreSQL store (`ov_openre`) |
| `set-definition-state.js` | disables or re-enables one slot's OpenRestream definition, **as Live** (per-slot rollback) | the PostgreSQL store (`ov_openre`) via the API |
Shell helpers for the session (paste once per SSH session):
@@@ -209,7 +209,7 @@ refuses that user's personal RTMP key (`refusesLiveIngest`).
openre.stream UI is not public yet (B0). Avoid Twitch for the rehearsal: Twitch is rewritten to
`rtmps://`, and this host has no OpenSSL ffmpeg (`OPENRE_FFMPEG_OPENSSL_PATH` is unset). Tell the
agent the slot id.
--2. **AGENT:** snapshot, then migrate that slot (commands in B2), then `pf --only slot --slot <id>`.
++2. **AGENT:** migrate that slot (commands in B2), then `pf --only slot --slot <id>`.
3. **OWNER:** switch it with `await api('/admin/openre/managed/<id>/ingest-authority', { method: 'PUT', body: { authority: 'openre' } })`.
On the Go Live page, press **Regenerate stream key**. The OpenRestream key is shown once. In OBS, set
the server to `rtmp://ingest.openre.stream:1936/live` and paste the key. Start streaming.
@@@ -271,17 +271,12 @@ during the window.
```bash
ID=<Live managed stream id>
pf --only service,db,live-env,events,slot --slot $ID # slot offline, owner has a subject, RTMP slot
--SNAP=/var/lib/openre/live-snapshot-$(date -u +%Y%m%d-%H%M%S).db
--sudo sqlite3 -readonly /opt/openvibe.live/data/live.db ".backup $SNAP"
--sudo sqlite3 "$SNAP" "PRAGMA journal_mode=DELETE;" # a .backup copy is WAL; the migration opens it read-only as ubuntu
--sudo chown ubuntu:ubuntu "$SNAP" && sudo chmod 600 "$SNAP" # it holds every Live user and stream key
--openre_as_service "node scripts/migrate-from-live.js --live-db $SNAP --slots $ID" # dry run
--openre_as_service "node scripts/migrate-from-live.js --live-db $SNAP --slots $ID --apply" # import + checklist
--sudo rm -f "$SNAP"
++openre_as_service "node scripts/migrate-from-live.js --live-db env:/etc/openvibe/live.env --slots $ID" # dry run
++openre_as_service "node scripts/migrate-from-live.js --live-db env:/etc/openvibe/live.env --slots $ID --apply" # import + checklist
pf --only slot --slot $ID # OpenRestream <id> (active, mirrored); N destination(s), held ones counted
```
--- The migration imports the slot as a definition with a **new key that nobody has seen**. Old keys
++- The migration reads production Live directly in a read-only PostgreSQL transaction and imports the slot as a definition with a **new key that nobody has seen**. Old keys
are never imported. It also imports the slot's restream destinations, keys sealed. Destinations
on private hosts, and the unbound destinations of a user with several slots, are **held**
(disabled, with the reason recorded in `migration_map`). It is idempotent.
ddiff --git a/package-lock.json b/package-lock.json
iindex eb5f3cf..a93fb68 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -9,7 +9,6 @@
"version": "0.1.0",
"license": "AGPL-3.0-only",
"dependencies": {
-- "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
@@@ -432,6 +431,7 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "devOptional": true,
"funding": [
{
"type": "github",
@@@ -457,37 +457,6 @@
"tsscmp": "^1.0.6"
}
},
-- "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/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@@ -512,30 +481,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",
@@@ -596,12 +541,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
-- "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",
@@@ -705,30 +644,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",
@@@ -757,15 +672,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/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
@@@ -829,15 +735,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",
@@@ -883,15 +780,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.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
@@@ -983,12 +871,6 @@
"node": "^12.20 || >= 14.13"
}
},
-- "node_modules/file-uri-to-path": {
-- "version": "1.0.0",
-- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
-- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
-- "license": "MIT"
-- },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@@ -1043,12 +925,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",
@@@ -1105,12 +981,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",
@@@ -1243,6 +1113,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "devOptional": true,
"funding": [
{
"type": "github",
@@@ -1265,12 +1136,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
-- "node_modules/ini": {
-- "version": "1.3.8",
-- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
-- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
-- "license": "ISC"
-- },
"node_modules/iovalkey": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
@@@ -1613,18 +1478,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/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@@ -1670,12 +1523,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
-- "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",
@@@ -1696,12 +1543,6 @@
"multicast-dns": "cli.js"
}
},
-- "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",
@@@ -1711,18 +1552,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/node-abi": {
-- "version": "3.96.0",
-- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
-- "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
-- "license": "MIT",
-- "dependencies": {
-- "semver": "^7.3.5"
-- },
-- "engines": {
-- "node": ">=10"
-- }
-- },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@@ -1808,15 +1637,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.127.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
@@@ -2017,33 +1837,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/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@@ -2061,16 +1854,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/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
@@@ -2131,35 +1914,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": "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/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
@@@ -2367,51 +2121,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",
@@@ -2436,24 +2145,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/string_decoder": {
-- "version": "1.3.0",
-- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
-- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
-- "license": "MIT",
-- "dependencies": {
-- "safe-buffer": "~5.2.0"
-- }
-- },
-- "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/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@@ -2482,34 +2173,6 @@
"node": ">=18"
}
},
-- "node_modules/tar-fs": {
-- "version": "2.1.5",
-- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
-- "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
-- "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/node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@@ -2571,18 +2234,6 @@
"dev": true,
"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/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
@@@ -2612,12 +2263,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/util-deprecate": {
-- "version": "1.0.2",
-- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-- "license": "MIT"
-- },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@@ -2716,12 +2361,6 @@
"dev": true,
"license": "MIT"
},
-- "node_modules/wrappy": {
-- "version": "1.0.2",
-- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-- "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 29f3d78..cfb3884 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,6 @@
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenRestream",
"dependencies": {
-- "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
ddiff --git a/scripts/cutover-preflight.js b/scripts/cutover-preflight.js
iindex f1789a1..1f48dd5 100644
---- a/scripts/cutover-preflight.js
++++ b/scripts/cutover-preflight.js
@@@ -30,9 +30,9 @@
* trust (GET, `{host}` and `{port}` replaced, JSON answer with a boolean `open` or `reachable`).
*
* OpenRestream and Events serve from PostgreSQL (ADR-035): their databases are read through the DATABASE_URL of their env
-- * files (--openre-db / --events-db take env:<file>, a postgres:// URL or a SQLite file); Live's is still its SQLite file.
++ * files (--openre-db / --events-db / --live-db take env:<file> or a postgres:// URL).
*
-- * Nothing is written anywhere: files are opened read-only and PostgreSQL is read in READ ONLY transactions, no secret value is
++ * Nothing is written anywhere: PostgreSQL is read in READ ONLY transactions, no secret value is
* printed, the only network traffic is DNS, one GET to /api/ready and one TCP connection with an
* RTMP handshake (no publish). Exit 0 when no check FAILs (--strict: also no WARN/MANUAL).
*/
@@@ -43,6 +43,7 @@ const net = require('net');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');
++const { parseEnvFile, liveDbUrl, openLiveDb } = require('./lib/live-db');
const DEFAULTS = Object.freeze({
root: '/opt/openre.stream',
@@@ -50,7 +51,7 @@ const DEFAULTS = Object.freeze({
liveEnv: '/etc/openvibe/live.env',
openreDb: 'env:/etc/openvibe/openre.env',
eventsDb: 'env:/etc/openvibe/events.env',
-- liveDb: '/opt/openvibe.live/data/live.db',
++ liveDb: 'env:/etc/openvibe/live.env',
api: 'http://127.0.0.1:4500',
openreUrl: 'http://127.0.0.1:4500',
liveUnit: 'openvibe-live.service',
@@@ -66,12 +67,6 @@ const WORKER_KINDS = Object.freeze(['rtmp-ingest', 'restream']);
// ── helpers ─────────────────────────────────────────────────────────────────
--/** KEY=VALUE lines (systemd EnvironmentFile / dotenv); null when the file cannot be read. */
--function parseEnvFile(text) {
-- if (text == null) return null;
-- return require('dotenv').parse(String(text));
--}
--
const isLoopback = (a) => /^127\./.test(a) || a === '::1' || a === 'localhost' || /^::ffff:127\./.test(a);
const isWildcard = (a) => a === '0.0.0.0' || a === '::' || a === '*' || a === '[::]';
const truthy = (v) => /^(1|true|yes|on)$/i.test(String(v || ''));
@@@ -139,20 +134,13 @@ function realDeps() {
interfaces: () => os.networkInterfaces(),
probe: rtmpProbe,
fetch: (url, opts) => fetch(url, opts),
-- openDb: (target) => {
-- if (/^postgres(ql)?:\/\//.test(target)) {
-- const silent = { log() {}, info() {}, warn() {}, error() {} };
-- return require('openvibe-sdk/db').createDb({ url: target, service: 'openre-preflight', max: 1, log: silent });
-- }
-- const Database = require('better-sqlite3');
-- return new Database(target, { readonly: true, fileMustExist: true });
-- },
++ openDb: (target) => openLiveDb(liveDbUrl(target, () => null), 'openre-preflight'),
};
}
/**
-- * fn(db, kind) on a read-only handle. `target`: env:<file> (that file's DATABASE_URL, never printed), a postgres:// URL
-- * or a SQLite file. PostgreSQL is read in one READ ONLY transaction (a write fails); SQLite is opened read-only.
++ * fn(db, kind) on a read-only handle. `target`: env:<file> (that file's DATABASE_URL, never printed), or a postgres:// URL.
++ * PostgreSQL is read in one READ ONLY transaction (a write fails).
*/
async function withDb(deps, target, fn) {
let where = String(target);
@@@ -167,13 +155,10 @@ async function withDb(deps, target, fn) {
if (!env.DATABASE_URL) return { error: `${file} has no DATABASE_URL` };
url = env.DATABASE_URL;
} else if (/^postgres(ql)?:\/\//.test(where)) where = 'the PostgreSQL URL given';
-- db = await deps.openDb(url);
++ db = await deps.openDb(liveDbUrl(url, () => null));
} catch (err) { return { error: `cannot open ${where} read-only: ${err.message}` }; }
try {
-- if (typeof db.tx === 'function') {
-- return await db.tx(async () => { await db.exec('SET TRANSACTION READ ONLY'); return await fn(db, 'postgresql'); });
-- }
-- return await fn(db, 'sqlite');
++ return await db.tx(async () => { await db.exec('SET TRANSACTION READ ONLY'); return await fn(db, 'postgresql'); });
} catch (err) {
return { error: `${where}: ${err.message}` };
} finally { try { await db.close(); } catch { /* read-only */ } }
@@@ -344,30 +329,25 @@ async function checkPort(ctx) {
}
async function checkDb(ctx) {
-- const r = await withDb(ctx.deps, ctx.opts.openreDb, async (db, kind) => {
++ const r = await withDb(ctx.deps, ctx.opts.openreDb, async (db) => {
let integrity = 'ok';
let fk = 0;
-- if (kind === 'sqlite') {
-- integrity = (await db.prepare('PRAGMA integrity_check').all()).map((x) => Object.values(x)[0]).join('; ');
-- fk = (await db.prepare('PRAGMA foreign_key_check').all()).length;
-- } else {
-- // PostgreSQL checks every key on write: what can still be wrong is a data page (checksums) or a key added NOT VALID.
-- const c = await db.prepare('SELECT checksum_failures AS n FROM pg_stat_database WHERE datname = current_database()').get();
-- if (c && Number(c.n) > 0) integrity = `${c.n} data checksum failure(s)`;
-- fk = Number((await db.prepare("SELECT count(*) AS n FROM pg_constraint WHERE contype = 'f' AND NOT convalidated").get()).n);
-- }
++ // PostgreSQL checks every key on write: what can still be wrong is a data page (checksums) or a key added NOT VALID.
++ const c = await db.prepare('SELECT checksum_failures AS n FROM pg_stat_database WHERE datname = current_database()').get();
++ if (c && Number(c.n) > 0) integrity = `${c.n} data checksum failure(s)`;
++ fk = Number((await db.prepare("SELECT count(*) AS n FROM pg_constraint WHERE contype = 'f' AND NOT convalidated").get()).n);
const counts = {};
for (const t of ['stream_definitions', 'ingest_keys', 'ingest_sessions', 'destinations', 'migration_map']) {
try { counts[t] = (await db.prepare(`SELECT count(*) AS n FROM ${t}`).get()).n; } catch { counts[t] = null; }
}
-- return { integrity, fk, kind, counts };
++ return { integrity, fk, counts };
});
if (r.error) return ['FAIL', r.error];
const counts = Object.entries(r.counts).map(([k, v]) => `${k} ${v == null ? '?' : v}`).join(', ');
-- const what = r.kind === 'sqlite' ? 'integrity_check' : 'PostgreSQL data checksums';
++ const what = 'PostgreSQL data checksums';
if (r.integrity !== 'ok') return ['FAIL', `${what} = ${r.integrity.slice(0, 300)}`];
-- if (r.fk) return ['FAIL', `${what} ok, but ${r.fk} foreign key ${r.kind === 'sqlite' ? 'violation(s)' : 'constraint(s) not validated'}`];
-- return ['PASS', `${what} ok, no foreign key ${r.kind === 'sqlite' ? 'violations' : 'left unvalidated'} (${counts})`];
++ if (r.fk) return ['FAIL', `${what} ok, but ${r.fk} foreign key constraint(s) not validated`];
++ return ['PASS', `${what} ok, no foreign key left unvalidated (${counts})`];
}
async function checkLiveEnv(ctx) {
ddiff --git a/scripts/lib/live-db.js b/scripts/lib/live-db.js
nnew file mode 100644
iindex 0000000..f693e27
---- /dev/null
++++ b/scripts/lib/live-db.js
@@@ -0,0 +1,28 @@
++'use strict';
++
++const { createDb } = require('openvibe-sdk/db');
++
++/** Parse a systemd EnvironmentFile without changing this process's environment. */
++function parseEnvFile(text) {
++ if (text == null) return null;
++ return require('dotenv').parse(String(text));
++}
++
++function liveDbUrl(target, readFile) {
++ if (target.startsWith('env:')) {
++ const file = target.slice(4);
++ const env = parseEnvFile(readFile(file));
++ if (!env) throw new Error(`cannot read ${file}`);
++ if (!env.DATABASE_URL) throw new Error(`${file} has no DATABASE_URL`);
++ target = env.DATABASE_URL;
++ }
++ if (!/^postgres(ql)?:\/\//.test(target)) {
++ throw new Error("Live's database is PostgreSQL; pass env:<file> or a postgres:// URL");
++ }
++ return target;
++}
++
++const silent = { log() {}, info() {}, warn() {}, error() {} };
++function openLiveDb(url, service) { return createDb({ url, max: 1, service, log: silent }); }
++
++module.exports = { parseEnvFile, liveDbUrl, openLiveDb };
ddiff --git a/scripts/migrate-from-live.js b/scripts/migrate-from-live.js
iindex 20b7601..8fa0576 100755
---- a/scripts/migrate-from-live.js
++++ b/scripts/migrate-from-live.js
@@@ -1,15 +1,15 @@
#!/usr/bin/env node
'use strict';
/**
-- * Import Live's stream slots and restream destinations into OpenRestream, from a read-only snapshot of
-- * Live's database, and print the per-channel RTMP cutover checklist.
++ * Import Live's stream slots and restream destinations from Live's PostgreSQL database
++ * in a read-only transaction, and print the per-channel RTMP cutover checklist.
*
-- * node scripts/migrate-from-live.js --live-db /path/to/live-snapshot.db # dry run
-- * node scripts/migrate-from-live.js --live-db /path/to/live-snapshot.db --apply # write
++ * node scripts/migrate-from-live.js # dry run; env:/etc/openvibe/live.env
++ * node scripts/migrate-from-live.js --live-db env:/etc/openvibe/live.env --apply # import
++ * node scripts/migrate-from-live.js --live-db postgres://user:pass@host/ov_live # explicit URL
* ... [--slots 12,31] [--checklist /path/to/checklist.md]
*
-- * Take the snapshot with `sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-snapshot.db"`
-- * (Live's DB_PATH in production; never point this at the live file). Uses the OpenRestream environment
++ * The Live env file is parsed only for DATABASE_URL and is never loaded into process.env. Uses the OpenRestream environment
* (.env or OPENRE_ENV_FILE) for its own database and OPENRE_SECRETS_KEY; in production set
* OPENRE_ENV_FILE=/etc/openvibe/openre.env and NODE_ENV=production (a unit sets NODE_ENV itself)
* — docs/cutover.md has the command. Prints no secret: old keys are not imported and the new
@@@ -17,34 +17,44 @@
*/
const fs = require('fs');
const path = require('path');
--const Database = require('better-sqlite3');
++const { liveDbUrl, openLiveDb } = require('./lib/live-db');
++const USAGE = 'usage: migrate-from-live.js [--live-db env:/etc/openvibe/live.env|postgres://...] [--apply] [--slots 1,2] [--checklist out.md]';
function arg(name) {
const i = process.argv.indexOf(`--${name}`);
return i > 0 ? process.argv[i + 1] : null;
}
--async function main() {
-- require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
++async function main({ config: suppliedConfig, openLive = openLiveDb, openRestream } = {}) {
++ if (process.argv.includes('--help') || process.argv.includes('-h')) { console.log(USAGE); return; }
++ if (!suppliedConfig) require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
const { load } = require('../server/config');
const { openRuntime } = require('../server/store');
const { migrate, checklist } = require('../server/migrate-live');
-- const liveDbPath = arg('live-db');
-- if (!liveDbPath) { console.error('usage: migrate-from-live.js --live-db <snapshot.db> [--apply] [--slots 1,2] [--checklist out.md]'); process.exit(2); }
++ const liveTarget = arg('live-db') || 'env:/etc/openvibe/live.env';
const apply = process.argv.includes('--apply');
-- const config = load();
++ const config = suppliedConfig || load();
if (apply && !config.secretsKey) { console.error('OPENRE_SECRETS_KEY is required to import destinations'); process.exit(2); }
-- const liveDb = new Database(liveDbPath, { readonly: true, fileMustExist: true });
-- const rt = await openRuntime({ config, log: { log() {}, warn: console.warn, error: console.error } });
-- const onlySlots = arg('slots') ? arg('slots').split(',').map(Number).filter(Number.isFinite) : null;
-- const report = await migrate({ liveDb, rt, apply, onlySlots });
-- const r = config.rtmp;
-- const md = checklist(report, { openreUrl: config.baseUrl, rtmpUrl: `rtmp://${r.publicHost}${r.publicPort === 1935 ? '' : `:${r.publicPort}`}/live` });
-- const out = arg('checklist');
-- if (out) { fs.writeFileSync(out, md + '\n'); console.log(`checklist written to ${out}`); } else console.log(md);
-- console.error(`[migrate] ${apply ? 'applied' : 'dry run'}: ${JSON.stringify(report.counts)}`);
-- liveDb.close();
-- rt.db.close();
++ const liveDb = openLive(liveDbUrl(liveTarget, (file) => fs.readFileSync(file, 'utf8')), 'openre-migration');
++ let rt;
++ try {
++ rt = await (openRestream || openRuntime)({ config, log: { log() {}, warn: console.warn, error: console.error } });
++ const onlySlots = arg('slots') ? arg('slots').split(',').map(Number).filter(Number.isFinite) : null;
++ const report = await liveDb.tx(async () => {
++ await liveDb.exec('SET TRANSACTION READ ONLY');
++ return migrate({ liveDb, rt, apply, onlySlots });
++ });
++ const r = config.rtmp;
++ const md = checklist(report, { openreUrl: config.baseUrl, rtmpUrl: `rtmp://${r.publicHost}${r.publicPort === 1935 ? '' : `:${r.publicPort}`}/live` });
++ const out = arg('checklist');
++ if (out) { fs.writeFileSync(out, md + '\n'); console.log(`checklist written to ${out}`); } else console.log(md);
++ console.error(`[migrate] ${apply ? 'applied' : 'dry run'}: ${JSON.stringify(report.counts)}`);
++ } finally {
++ await liveDb.close();
++ if (rt) await rt.db.close();
++ }
}
if (require.main === module) main();
++
++module.exports = { main };
ddiff --git a/server/migrate-live.js b/server/migrate-live.js
iindex 040db8a..78c8e0a 100644
---- a/server/migrate-live.js
++++ b/server/migrate-live.js
@@@ -1,7 +1,7 @@
'use strict';
/**
* Import OpenVibe.Live stream slots (managed_streams) and their restream destinations from a
-- * read-only snapshot of Live's database into OpenRestream (scripts/migrate-from-live.js is the CLI).
++ * read-only Live PostgreSQL database into OpenRestream (scripts/migrate-from-live.js is the CLI).
*
* Rules (ADR-009, plan §8.1):
* - Live's database is opened read-only; nothing is written to it.
@@@ -19,9 +19,12 @@
const { validateDestinationUrl } = require('./destination-url');
async function columns(db, table) {
-- try { return new Set((await db.prepare(`PRAGMA table_info(${table})`).all()).map(c => c.name)); } catch { return new Set(); }
++ return new Set((await db.prepare('SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?').all(table)).map(c => c.name));
}
++const off = (v) => v === 0 || v === false || v === '0';
++const on = (v) => v === 1 || v === true || v === '1';
++
/**
* The definition protocols for a Live slot (T4 decision 2): 'whip' is an input alias for 'webrtc',
* 'webrtc' and 'jsmpeg' slots import as themselves (they are unadmittable until their worker ships
@@@ -35,10 +38,10 @@ function protocolOf(ms) {
}
function recordingModeOf(ms, channel) {
-- let vod = !channel ? true : Boolean(channel.vod_recording_enabled) && !channel.force_vod_recording_disabled;
-- if (vod && ms.slot_vod_recording_enabled === 0) vod = false;
++ let vod = !channel ? true : on(channel.vod_recording_enabled) && !on(channel.force_vod_recording_disabled);
++ if (vod && off(ms.slot_vod_recording_enabled)) vod = false;
if (vod) return 'vod';
-- return ms.slot_clip_recording_enabled === 0 ? 'none' : 'clips';
++ return off(ms.slot_clip_recording_enabled) ? 'none' : 'clips';
}
function visibilityOf(ms, channel) {
@@@ -53,7 +56,7 @@ function visibilityOf(ms, channel) {
async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () => Date.now() }) {
const { db, store, config } = rt;
const msCols = await columns(liveDb, 'managed_streams');
-- if (!msCols.size) throw new Error('the Live snapshot has no managed_streams table');
++ if (!msCols.size) throw new Error('the Live database has no managed_streams table');
const rdCols = await columns(liveDb, 'restream_destinations');
const laCols = await columns(liveDb, 'linked_accounts');
const chCols = await columns(liveDb, 'channels');
@@@ -95,19 +98,19 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =
entry.reason = 'already imported';
count('skipped');
} else {
-- const subject = subjectOf ? (subjectOf.get(ms.user_id) || {}).subject_id : null;
++ const subject = subjectOf ? ((await subjectOf.get(ms.user_id)) || {}).subject_id : null;
if (!subject) {
entry.status = 'held';
entry.reason = `Live user ${ms.user_id} has no canonical subject yet (it is recorded when they next sign in to Live); the slot is imported on a later run`;
await record({ source_type: 'managed_stream', source_id: String(ms.id), status: 'held', reason: entry.reason });
count('held');
-- } else if (ms.is_banned) {
++ } else if (on(ms.is_banned)) {
entry.status = 'excluded';
entry.reason = 'the Live account is banned';
await record({ source_type: 'managed_stream', source_id: String(ms.id), status: 'excluded', reason: entry.reason });
count('excluded');
} else {
-- const chRow = channelOf ? channelOf.get(ms.user_id) : null;
++ const chRow = channelOf ? await channelOf.get(ms.user_id) : null;
const fields = {
owner_subject: subject,
title: ms.title || `${ms.display_name || ms.username}'s stream`,
@@@ -137,9 +140,9 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =
}
// Destinations: the slot's own, plus unbound ones when the user has exactly one slot.
-- const own = destsOfSlot ? destsOfSlot.all(ms.id) : [];
-- const unbound = unboundOfUser ? unboundOfUser.all(ms.user_id) : [];
-- const oneSlot = (await slotsOfUser.get(ms.user_id)).n === 1;
++ const own = destsOfSlot ? await destsOfSlot.all(ms.id) : [];
++ const unbound = unboundOfUser ? await unboundOfUser.all(ms.user_id) : [];
++ const oneSlot = Number((await slotsOfUser.get(ms.user_id)).n) === 1;
const candidates = [...own.map(d => ({ d, unbound: false })), ...unbound.map(d => ({ d, unbound: true }))];
for (const { d, unbound: isUnbound } of candidates) {
const di = { live_id: d.id, platform: d.platform, name: d.name || d.platform, status: null, reason: null, destination_id: null, oauth_linked: Boolean(d.connection_id) };
@@@ -173,8 +176,8 @@ async function migrate({ liveDb, rt, apply = false, onlySlots = null, now = () =
stream_key: d.stream_key || '',
srt_passphrase: d.srt_passphrase || '',
srt_latency_ms: d.srt_latency_ms == null ? undefined : d.srt_latency_ms,
-- enabled: Boolean(d.enabled),
-- auto_start: Boolean(d.auto_start),
++ enabled: on(d.enabled),
++ auto_start: on(d.auto_start),
quality_preset: ['auto', 'low', 'medium', 'high', 'ultra', 'source'].includes(d.quality_preset) ? d.quality_preset : 'auto',
custom_video_bitrate: d.custom_video_bitrate == null ? undefined : d.custom_video_bitrate,
custom_audio_bitrate: d.custom_audio_bitrate == null ? undefined : d.custom_audio_bitrate,
ddiff --git a/test/migration.test.js b/test/migration.test.js
iindex 9120030..e095aff 100644
---- a/test/migration.test.js
++++ b/test/migration.test.js
@@@ -1,29 +1,30 @@
'use strict';
--// Migration from a Live DB snapshot: read-only, new keys (never the old ones), destinations
++// Migration from Live PostgreSQL: read-only, new keys (never the old ones), destinations
// sealed, held/excluded rows recorded with reasons, idempotent re-runs, and the checklist.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
--const { execFileSync } = require('child_process');
--const Database = require('better-sqlite3');
--const { runtime, tmpDir, testEnv, suite, ROOT } = require('./helpers');
--const { migrate, checklist } = require('../server/migrate-live');
++const { PGlite } = require('@electric-sql/pglite');
++const { createDb } = require('openvibe-sdk/db');
++const { liveDbUrl } = require('../scripts/lib/live-db');
++const { main: migrationCli } = require('../scripts/migrate-from-live');
++const { runtime, tmpDir, suite } = require('./helpers');
++const { migrate, checklist, recordingModeOf } = require('../server/migrate-live');
const { hashIngestKey } = require('../server/secrets');
const t = suite('migration');
const SUBJECT_A = 'usr_01J00000000000000000000A01';
const SUBJECT_B = 'usr_01J00000000000000000000B01';
--function liveSnapshot(dir) {
-- const file = path.join(dir, 'live-snapshot.db');
-- const db = new Database(file);
-- db.exec(`
-- CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, display_name TEXT, password_hash TEXT, stream_key TEXT, is_banned INTEGER DEFAULT 0);
-- CREATE TABLE channels (id INTEGER PRIMARY KEY, user_id INTEGER, vod_recording_enabled INTEGER DEFAULT 1, force_vod_recording_disabled INTEGER DEFAULT 0, default_vod_visibility TEXT DEFAULT 'public');
-- CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);
-- CREATE TABLE managed_streams (id INTEGER PRIMARY KEY, user_id INTEGER, slug TEXT, title TEXT, description TEXT, protocol TEXT, stream_key TEXT,
++async function liveDatabase() {
++ const db = createDb({ pglite: new PGlite(), service: 'live-migration-test' });
++ await db.exec(`
++ CREATE TABLE users (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, username TEXT, display_name TEXT, password_hash TEXT, stream_key TEXT, is_banned INTEGER DEFAULT 0);
++ CREATE TABLE channels (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id INTEGER, vod_recording_enabled INTEGER DEFAULT 1, force_vod_recording_disabled INTEGER DEFAULT 0, default_vod_visibility TEXT DEFAULT 'public');
++ CREATE TABLE linked_accounts (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);
++ CREATE TABLE managed_streams (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id INTEGER, slug TEXT, title TEXT, description TEXT, protocol TEXT, stream_key TEXT,
streaming_method TEXT, default_vod_visibility TEXT, slot_vod_recording_enabled INTEGER DEFAULT 1, slot_clip_recording_enabled INTEGER DEFAULT 1);
-- CREATE TABLE restream_destinations (id INTEGER PRIMARY KEY, user_id INTEGER, managed_stream_id INTEGER, platform TEXT, name TEXT, server_url TEXT,
++ CREATE TABLE restream_destinations (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id INTEGER, managed_stream_id INTEGER, platform TEXT, name TEXT, server_url TEXT,
stream_key TEXT, enabled INTEGER, auto_start INTEGER, quality_preset TEXT, connection_id INTEGER, srt_latency_ms INTEGER, srt_passphrase TEXT,
custom_video_bitrate INTEGER, custom_audio_bitrate INTEGER, custom_fps INTEGER, custom_encoder_preset TEXT);
INSERT INTO users VALUES (1, 'japaneseoldguy', 'JOG', 'x', 'personalkey111', 0), (2, 'nosubject', 'NS', 'x', 'k2', 0), (3, 'banned', 'B', 'x', 'k3', 1), (4, 'twoslots', 'TS', 'x', 'k4', 0);
@@@ -41,14 +42,13 @@ function liveSnapshot(dir) {
(102, 1, 10, 'custom', 'LAN box', 'rtmp://192.168.1.20/live', 'lan-secret-333', 1, 1, 'auto', NULL, NULL, NULL, NULL, NULL, NULL, NULL),
(103, 4, NULL, 'kick', 'Kick', 'rtmps://fa723fc1b171.global-contribute.live-video.net', 'kick-secret-44', 1, 1, 'auto', NULL, NULL, NULL, NULL, NULL, NULL, NULL);
`);
-- db.close();
-- return file;
++ return db;
}
t('dry run writes nothing', async () => {
const dir = tmpDir();
const rt = await runtime({ dir });
-- const live = new Database(liveSnapshot(dir), { readonly: true });
++ const live = await liveDatabase();
const report = await migrate({ liveDb: live, rt, apply: false });
assert.strictEqual((await rt.db.prepare('SELECT COUNT(*) AS n FROM stream_definitions').get()).n, 0);
assert.strictEqual((await rt.db.prepare('SELECT COUNT(*) AS n FROM migration_map').get()).n, 0);
@@@ -58,16 +58,19 @@ t('dry run writes nothing', async () => {
let dir;
let rt;
--let liveFile;
++let live;
t('apply: slots with a subject are imported with new keys; the rest are held or excluded with reasons', async () => {
dir = tmpDir();
rt = await runtime({ dir, env: { OPENRE_DEST_ALLOW_PRIVATE: '0' } });
-- liveFile = liveSnapshot(dir);
-- const before = fs.readFileSync(liveFile);
-- const live = new Database(liveFile, { readonly: true });
-- const report = await migrate({ liveDb: live, rt, apply: true });
-- live.close();
-- assert.ok(before.equals(fs.readFileSync(liveFile)), 'the Live snapshot is untouched');
++ live = await liveDatabase();
++ const report = await live.tx(async () => {
++ await live.exec('SET TRANSACTION READ ONLY');
++ return migrate({ liveDb: live, rt, apply: true });
++ });
++ await assert.rejects(live.tx(async () => {
++ await live.exec('SET TRANSACTION READ ONLY');
++ await live.prepare('DELETE FROM managed_streams').run();
++ }), /read-only transaction/);
const map = Object.fromEntries((await rt.db.prepare('SELECT source_type, source_id, status, reason, target_id FROM migration_map').all()).map(r => [`${r.source_type}:${r.source_id}`, r]));
assert.strictEqual(map['managed_stream:10'].status, 'imported');
@@@ -126,30 +129,53 @@ t('re-running is idempotent; a held slot is imported once its user has a subject
keys: (await rt.db.prepare('SELECT COUNT(*) AS n FROM ingest_keys').get()).n,
});
const before = await counts();
-- let live = new Database(liveFile, { readonly: true });
const again = await migrate({ liveDb: live, rt, apply: true });
-- live.close();
assert.deepStrictEqual(await counts(), before);
assert.strictEqual(again.counts.imported, 0);
-- const w = new Database(liveFile);
-- await w.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '60', 'usr_01J00000000000000000000D01')").run();
-- w.close();
-- live = new Database(liveFile, { readonly: true });
++ await live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '60', 'usr_01J00000000000000000000D01')").run();
await migrate({ liveDb: live, rt, apply: true });
-- live.close();
assert.strictEqual((await counts()).defs, before.defs + 1);
assert.strictEqual((await rt.db.prepare("SELECT status FROM migration_map WHERE source_type = 'managed_stream' AND source_id = '11'").get()).status, 'imported');
});
--t('the CLI runs a dry run against a snapshot and prints the checklist', async () => {
-- const d = tmpDir();
-- const file = liveSnapshot(d);
-- const env = await testEnv(d);
-- const out = execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'migrate-from-live.js'), '--live-db', file], {
-- env: { ...process.env, ...env, OPENRE_ENV_FILE: '/nonexistent' }, cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
-- });
-- assert.match(out, /OpenRestream RTMP cutover checklist \(dry run/);
-- assert.match(out, /@twoslots/);
++t('Live database target accepts env files and PostgreSQL URLs, never a SQLite path', () => {
++ const before = process.env.DATABASE_URL;
++ assert.strictEqual(liveDbUrl('env:/etc/openvibe/live.env', () => 'DATABASE_URL=postgres://live:secret@localhost/ov_live'), 'postgres://live:secret@localhost/ov_live');
++ assert.strictEqual(process.env.DATABASE_URL, before, 'Live credentials do not enter process.env');
++ assert.strictEqual(liveDbUrl('postgres://localhost/ov_live', () => null), 'postgres://localhost/ov_live');
++ assert.throws(() => liveDbUrl('/tmp/live.db', () => null), /Live's database is PostgreSQL/);
++});
++
++t('the CLI reads Live DATABASE_URL and prints the dry-run checklist', async () => {
++ const dir = tmpDir();
++ const rt = await runtime({ dir });
++ const live = await liveDatabase();
++ const envFile = path.join(dir, 'live.env');
++ fs.writeFileSync(envFile, 'DATABASE_URL=postgres://fixture/ov_live\n');
++ const argv = process.argv;
++ const log = console.log;
++ const error = console.error;
++ let output = '';
++ let target;
++ try {
++ process.argv = [process.execPath, 'scripts/migrate-from-live.js', '--live-db', `env:${envFile}`];
++ console.log = (line) => { output += line; };
++ console.error = () => {};
++ await migrationCli({ config: rt.config, openLive: (url) => { target = url; return live; }, openRestream: async () => rt });
++ } finally {
++ process.argv = argv;
++ console.log = log;
++ console.error = error;
++ }
++ assert.strictEqual(target, 'postgres://fixture/ov_live');
++ assert.match(output, /OpenRestream RTMP cutover checklist \(dry run/);
++ assert.match(output, /@twoslots/);
++});
++
++t('recording flags accept PostgreSQL booleans and Live integer values', () => {
++ assert.strictEqual(recordingModeOf({ slot_vod_recording_enabled: false, slot_clip_recording_enabled: false }, { vod_recording_enabled: true, force_vod_recording_disabled: false }), 'none');
++ assert.strictEqual(recordingModeOf({ slot_vod_recording_enabled: 1, slot_clip_recording_enabled: 0 }, { vod_recording_enabled: 1, force_vod_recording_disabled: 0 }), 'vod');
++ assert.strictEqual(recordingModeOf({ slot_vod_recording_enabled: 1, slot_clip_recording_enabled: 1 }, { vod_recording_enabled: true, force_vod_recording_disabled: true }), 'clips');
});
t.run();
ddiff --git a/test/preflight.test.js b/test/preflight.test.js
iindex 0806e2f..e2cf9ea 100644
---- a/test/preflight.test.js
++++ b/test/preflight.test.js
@@@ -1,18 +1,18 @@
'use strict';
// scripts/cutover-preflight.js against a mocked host: files, commands, DNS, interfaces, the port
// probe and /api/ready are fakes; OpenRestream's database is a real PostgreSQL one (PGlite) read in READ ONLY transactions,
--// Events' and Live's are real SQLite files opened read-only (Events' production PostgreSQL has the same tables).
++// Events' and Live's databases are PGlite handles read in READ ONLY transactions.
// Covers the host as it is today (2026-09-23: everything the cutover still needs fails), a host
// ready for the first slot, each failure on its own, the off-host port check, the probe URL, the
// per-slot check, and that no secret value ever reaches the output.
const assert = require('assert');
const net = require('net');
--const path = require('path');
--const Database = require('better-sqlite3');
++const { PGlite } = require('@electric-sql/pglite');
++const { createDb } = require('openvibe-sdk/db');
const { openDb } = require('../server/db');
const { load } = require('../server/config');
const { preflight, parseArgs, format, parseSsListeners, rtmpProbe, sameSecret, withDb, CHECKS } = require('../scripts/cutover-preflight');
--const { tmpDir, freePort, suite } = require('./helpers');
++const { freePort, suite } = require('./helpers');
const t = suite('preflight');
@@@ -26,9 +26,8 @@ const LIVE_PID = 4242;
// ── fixtures: three databases ──────────────────────────────────────────────
async function makeDbs() {
-- const dir = tmpDir();
-- // OpenRestream's database: PGlite in memory, reached as the target 'pglite:openre' (host() below hands it out).
-- const openrePath = 'pglite:openre';
++ // OpenRestream's database: PGlite in memory, reached through a fixture URL.
++ const openrePath = 'postgres://fixture/openre';
const o = await openDb(load({ NODE_ENV: 'test' }), { memory: true });
const now = Date.now();
const worker = o.prepare(`INSERT INTO workers (id, kind, generation, release, state, endpoints, started_at, heartbeat_at) VALUES (?, ?, ?, ?, ?, '{}', ?, ?)`);
@@@ -38,35 +37,31 @@ async function makeDbs() {
await o.prepare(`INSERT INTO external_refs (definition_id, service, type, ref_id, created_at) VALUES ('str_1', 'live', 'managed_stream', '12', ?)`).run(now);
await o.prepare(`INSERT INTO migration_map (source_system, source_type, source_id, target_type, target_id, status, imported_at) VALUES ('live', 'managed_stream', '12', 'stream', 'str_1', 'imported', ?)`).run(now);
-- const eventsPath = path.join(dir, 'events.db');
-- const e = new Database(eventsPath);
-- e.exec(`CREATE TABLE subscriptions (id TEXT PRIMARY KEY, consumer TEXT NOT NULL, topic_pattern TEXT NOT NULL, endpoint TEXT NOT NULL, secret TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, retry_policy TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, project_id TEXT, env TEXT NOT NULL DEFAULT 'production');
++ const eventsPath = 'postgres://fixture/events';
++ const e = createDb({ pglite: new PGlite(), service: 'events-preflight-test' });
++ await e.exec(`CREATE TABLE subscriptions (id TEXT PRIMARY KEY, consumer TEXT NOT NULL, topic_pattern TEXT NOT NULL, endpoint TEXT NOT NULL, secret TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, retry_policy TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, project_id TEXT, env TEXT NOT NULL DEFAULT 'production');
CREATE TABLE deliveries (event_id TEXT NOT NULL, subscription_id TEXT NOT NULL, seq INTEGER NOT NULL, priority INTEGER NOT NULL, attempt INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL, next_attempt_at INTEGER, last_error TEXT, last_status INTEGER, delivered_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (event_id, subscription_id));`);
-- e.close();
--
-- const livePath = path.join(dir, 'live.db');
-- const l = new Database(livePath);
-- l.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, is_banned INTEGER DEFAULT 0);
-- CREATE TABLE managed_streams (id INTEGER PRIMARY KEY, user_id INTEGER, slug TEXT, title TEXT, protocol TEXT, streaming_method TEXT, ingest_authority TEXT DEFAULT 'live', openre_stream_id TEXT);
-- CREATE TABLE streams (id INTEGER PRIMARY KEY, managed_stream_id INTEGER, is_live INTEGER);
-- CREATE TABLE linked_accounts (id INTEGER PRIMARY KEY, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);
++
++ const livePath = 'postgres://fixture/live';
++ const l = createDb({ pglite: new PGlite(), service: 'live-preflight-test' });
++ await l.exec(`CREATE TABLE users (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, username TEXT, is_banned INTEGER DEFAULT 0);
++ CREATE TABLE managed_streams (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id INTEGER, slug TEXT, title TEXT, protocol TEXT, streaming_method TEXT, ingest_authority TEXT DEFAULT 'live', openre_stream_id TEXT);
++ CREATE TABLE streams (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, managed_stream_id INTEGER, is_live INTEGER);
++ CREATE TABLE linked_accounts (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id INTEGER, service TEXT, service_user_id TEXT, subject_id TEXT);
INSERT INTO users (id, username) VALUES (5, 'rehearsal');
INSERT INTO managed_streams (id, user_id, slug, title, protocol, streaming_method) VALUES (12, 5, 'test', 'Test', 'rtmp', 'obs');
INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (5, 'network', '77', 'usr_01J0000000000000000000000A');`);
-- l.close();
-- return { dir, openre: o, openrePath, eventsPath, livePath };
++ return { openre: o, openrePath, events: e, eventsPath, live: l, livePath };
}
async function addSubscription(dbs, { secret = EVENTS_SECRET, enabled = 1, id = 'sub_1' } = {}) {
-- const e = new Database(dbs.eventsPath);
++ const e = dbs.events;
await e.prepare(`INSERT INTO subscriptions (id, consumer, topic_pattern, endpoint, secret, enabled, created_at, updated_at) VALUES (?, 'live', 'openre.session.*', 'http://127.0.0.1:3000/internal/openre-events', ?, ?, 0, 0)`).run(id, secret, enabled);
-- e.close();
}
async function addDelivery(dbs, status, n = 1) {
-- const e = new Database(dbs.eventsPath);
++ const e = dbs.events;
for (let i = 0; i < n; i++) await e.prepare("INSERT INTO deliveries (event_id, subscription_id, seq, priority, status, created_at, updated_at) VALUES (?, 'sub_1', ?, 1, ?, 0, 0)").run(`evt_${status}_${i}`, i, status);
-- e.close();
}
// ── fixtures: the host ─────────────────────────────────────────────────────
@@@ -129,7 +124,7 @@ function host(dbs, over = {}) {
if (h.probeUrl) return { status: h.probeUrl.status || 200, ok: (h.probeUrl.status || 200) < 300, json: async () => h.probeUrl.body };
throw new Error(`unexpected fetch ${url}`);
},
-- openDb: h.openDb || ((p) => (p === 'pglite:openre' ? keepOpen(dbs.openre) : new Database(p, { readonly: true, fileMustExist: true }))),
++ openDb: h.openDb || ((p) => ({ 'postgres://fixture/openre': keepOpen(dbs.openre), 'postgres://fixture/events': keepOpen(dbs.events), 'postgres://fixture/live': keepOpen(dbs.live) })[p] || (() => { throw new Error(`unavailable PostgreSQL target ${p}`); })()),
};
deps.h = h;
return deps;
@@@ -358,22 +353,25 @@ t('events: wrong secret, disabled, duplicates, dead deliveries fail; waiting del
assert.match(r.events.detail, /cannot open \/nonexistent\/events\.db read-only/);
});
--t('db: integrity_check and foreign keys from a read-only handle', async () => {
++t('db: PostgreSQL checks and foreign keys from a read-only handle', async () => {
const dbs = await makeDbs();
let r = byId(await preflight(opts(dbs, ['--only', 'db']), host(dbs)));
assert.strictEqual(r.db.status, 'PASS');
assert.match(r.db.detail, /stream_definitions 1, ingest_keys 0, ingest_sessions 0, destinations 0, migration_map 1/);
const corrupt = () => ({
-- prepare: (sql) => ({ all: () => (/integrity_check/.test(sql) ? [{ integrity_check: '*** in database main *** Page 7 is never used' }] : []), get: () => ({ n: 0 }) }),
-- close() {},
++ tx: async (fn) => fn(), exec: async () => {},
++ prepare: (sql) => ({ get: async () => /pg_stat_database/.test(sql) ? { n: 2 } : { n: 0 } }),
++ close: async () => {},
});
r = byId(await preflight(opts(dbs, ['--only', 'db']), host(dbs, { openDb: corrupt })));
assert.strictEqual(r.db.status, 'FAIL');
-- assert.match(r.db.detail, /Page 7 is never used/);
++ assert.match(r.db.detail, /2 data checksum failure\(s\)/);
// the preflight's PostgreSQL transaction really is read-only
const w = await withDb(host(dbs), dbs.openrePath, async (db) => await db.prepare('DELETE FROM migration_map').run());
assert.match(w.error, /read-only transaction/);
assert.strictEqual(Number((await dbs.openre.prepare('SELECT count(*) AS n FROM migration_map').get()).n), 1);
++ const fromEnv = await withDb(host(dbs, { liveEnv: { ...host(dbs).h.liveEnv, DATABASE_URL: dbs.livePath } }), 'env:/etc/openvibe/live.env', async (db) => db.prepare('SELECT count(*) AS n FROM managed_streams').get());
++ assert.strictEqual(fromEnv.n, 1, 'env:<file> resolves Live DATABASE_URL');
});
t('slot: offline RTMP slot with a subject and a migrated definition passes; live now / no subject fail', async () => {
@@@ -381,10 +379,10 @@ t('slot: offline RTMP slot with a subject and a migrated definition passes; live
let r = byId(await preflight(opts(dbs, ['--only', 'slot', '--slot', '12']), host(dbs)));
assert.strictEqual(r.slot.status, 'PASS', r.slot.detail);
assert.match(r.slot.detail, /slot 12 \(@rehearsal, rtmp\/obs\) authority live; OpenRestream str_1 \(active, mirrored\); 0 destination\(s\)/);
-- const l = new Database(dbs.livePath);
++ const l = dbs.live;
await l.prepare('INSERT INTO streams (managed_stream_id, is_live) VALUES (12, 1)').run();
await l.prepare('DELETE FROM linked_accounts').run();
-- l.close();
++
r = byId(await preflight(opts(dbs, ['--only', 'slot', '--slot', '12']), host(dbs)));
assert.strictEqual(r.slot.status, 'FAIL');
assert.match(r.slot.detail, /live on Live right now/);
@@@ -402,10 +400,10 @@ t('slot all: one line per Live slot by the same rules, read-only, FAIL if any sl
assert.strictEqual(r.ok, true);
assert.strictEqual(byId(r)['slot:12'].detail, byId(await preflight(opts(dbs, ['--only', 'slot', '--slot', '12']), host(dbs))).slot.detail);
-- const l = new Database(dbs.livePath);
++ const l = dbs.live;
await l.prepare("INSERT INTO users (id, username) VALUES (6, 'nosub')").run();
await l.prepare("INSERT INTO managed_streams (id, user_id, slug, title, protocol, streaming_method) VALUES (13, 6, 'x', 'X', 'rtmp', 'obs'), (14, 5, 'y', 'Y', 'webrtc', 'browser')").run();
-- l.close();
++
const before = (await dbs.openre.prepare('SELECT count(*) AS n FROM migration_map').get()).n;
r = await preflight(opts(dbs, ['--only', 'slot', '--slot', 'all']), host(dbs));
assert.deepStrictEqual(r.results.map((x) => [x.id, x.status]), [['slot:12', 'PASS'], ['slot:13', 'FAIL'], ['slot:14', 'FAIL']]);
@@@ -420,9 +418,9 @@ t('slot all: one line per Live slot by the same rules, read-only, FAIL if any sl
assert.deepStrictEqual(r.results.filter((x) => x.id.startsWith('slot')).map((x) => x.id), ['slot:12', 'slot:13', 'slot:14']);
const empty = await makeDbs();
-- const e = new Database(empty.livePath);
++ const e = empty.live;
await e.prepare('DELETE FROM managed_streams').run();
-- e.close();
++
r = await preflight(opts(empty, ['--only', 'slot', '--slot', 'all']), host(empty));
assert.strictEqual(r.results[0].status, 'WARN');
});