openvibers/openvibe.media Commits

Storage-tier policy as revisioned configuration (WS-C task 7): media.storage_tier on openvibe-shared/config 1.14.0: revision 1 imported from media_settings, PUT /tiers/settings is one validated revision (types, 1-100 percentages, interval floors, threshold order; refused changes change nothing, 422), the admin config routes (list, history, rollback), media_settings mirrors the active revision for older releases, the sweep restarts with a new policy; pins shared 1.14.0, contracts 0.52.0

OP OpenVibers committed 26 Sept 2026 · 41b548ec17 · 8 files, +251 −42


Changes

ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex a753399..b8ec6cb 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -756,3 +756,12 @@ Not run against production yet: it needs the owner's go-ahead.
operation moved onto the job system).
- The segment-native timeline.
- App assets (emotes and sounds, the `assets` table) are not projected yet. The `asset` kind exists for v2 uploads.
++
++## Storage-tier policy as configuration
++
++The tiering thresholds (`DEFAULTS` in `server/vod/vod-storage.js`) are the namespace `media.storage_tier` of `openvibe-shared/config` (`server/vod/tier-config.js`, WS-C task 7).
++- **Revisions:** every change is a revision, recording who made it and why. `PUT /api/v1/:app/admin/storage/tiers/settings` merges its keys into one. The whole policy is validated first: types, percentages 1–100, interval floors, and the order the thresholds need (`localLowWaterPct` < `hotDiskPressurePct` ≤ `criticalDiskPct`, `minFreeGb` ≤ `targetFreeGb`). A refused change changes nothing and answers 422 `config.invalid` with the rules broken.
++- **Admin routes:** `…/admin/storage/config` lists the namespace, `…/config/media.storage_tier/history` shows the history, and `…/config/media.storage_tier/rollback` rolls back as a new revision.
++- **Revision 1** is what `media_settings` held.
++- **Mirror:** every activation writes the active revision's explicit keys through to `media_settings` (`storage_tier.*`) and removes the rest, so a release that reads those rows sees the same policy. The rows go in the ADR-028 contract step.
++- **Sweep:** a running sweep restarts with the new policy.
ddiff --git a/package-lock.json b/package-lock.json
iindex 2de1185..92a2601 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,9 +16,9 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
"sharp": "^0.33.4"
}
},
@@@ -1919,9 +1919,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "version": "0.52.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
++ "integrity": "sha512-EVkhyQwoA291JmH9K1Yt9lddL1SJgjJxXB2ej7mkCjGaHnn7GHuSyXddwmd8SS6WWxvZvrV8w+glZaGTRwz/Pg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1944,9 +1944,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.13.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
-- "integrity": "sha512-aodJtwID5ZVMc8usa0hxf2YZoG8AivhI65mrk0sv6i3EHYCV9vTJCtaALpxvgsZnTzYtSg08CtzQh8GfXmcmtg==",
++ "version": "1.14.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
++ "integrity": "sha512-pID6sc4YQVyI0AeSmQGms3KGWxBxvES8dOXdkYmitIieINwSjeBbFcmzBTwhWoob2i02Pt/FT3rBBrykajKTLA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex e946693..e62d4af 100644
---- a/package.json
++++ b/package.json
@@@ -11,7 +11,7 @@
},
"license": "MIT",
"dependencies": {
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
@@@ -20,7 +20,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
"sharp": "^0.33.4"
}
}
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex ceac9cc..b421708 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -315,31 +315,42 @@ router.get('/tiers', (req, res) => {
});
// ── PUT /tiers/settings — update tier settings ───────────────
--router.put('/tiers/settings', (req, res) => {
++// One revision of the media.storage_tier configuration (server/vod/tier-config.js): validated as a whole
++// (422 with the rules broken), recorded with the app that asked and the reason; the sweep restarts with it.
++router.put('/tiers/settings', async (req, res) => {
try {
-- const allowed = Object.keys(vodStorage.DEFAULTS);
const updates = {};
-- for (const key of allowed) {
-- if (req.body?.[key] !== undefined) {
-- let val = req.body[key];
-- // Coerce types to match defaults
-- if (typeof vodStorage.DEFAULTS[key] === 'number') val = Number(val);
-- if (typeof vodStorage.DEFAULTS[key] === 'boolean') val = !!val;
-- vodStorage.setSetting(key, val);
-- updates[key] = val;
-- }
++ for (const key of Object.keys(vodStorage.DEFAULTS)) {
++ if (req.body?.[key] === undefined) continue;
++ let val = req.body[key];
++ if (typeof vodStorage.DEFAULTS[key] === 'number') val = Number(val);
++ if (typeof vodStorage.DEFAULTS[key] === 'boolean') val = val === true || val === 'true' || val === 1 || val === '1';
++ updates[key] = val;
}
-- // Restart sweep timer with new settings
-- vodStorage.stop();
-- vodStorage.start();
-- console.log(`[Admin] Storage tier settings updated (${req.appId}):`, updates);
-- res.json({ ok: true, settings: vodStorage.getSettings() });
++ if (!Object.keys(updates).length) return res.status(400).json({ error: `Nothing to change; known settings: ${Object.keys(vodStorage.DEFAULTS).join(', ')}` });
++ const snap = await vodStorage.setSettings(updates, { actor: { type: 'service', id: req.appId }, reason: String(req.body?.reason || 'PUT /tiers/settings').slice(0, 300) });
++ console.log(`[Admin] Storage tier settings: revision ${snap.revision} (${req.appId}):`, updates);
++ res.json({ ok: true, revision: snap.revision, settings: vodStorage.getSettings() });
} catch (err) {
++ if (err && err.status && err.code) return res.status(err.status).json({ error: err.message, code: err.code, errors: err.errors || undefined });
console.error('[Admin] Storage tier settings error:', err.message);
res.status(500).json({ error: 'Failed to update settings' });
}
});
++// The configuration model's own routes for this namespace (openvibe-shared/config): current values,
++// history, apply a whole revision, roll back. /api/v1/:app/admin/storage/config…
++// Built on first use (the store reads the database), behind this router's app-key authentication.
++let configRoutes = null;
++router.use('/config', (req, res, next) => {
++ if (!configRoutes) {
++ configRoutes = require('openvibe-shared/config').adminRoutes([vodStorage.tierConfig.get(vodStorage.DEFAULTS)], {
++ basePath: '/', requireAdmin: (_rq, _rs, nx) => nx(), actor: (rq) => ({ type: 'service', id: rq.appId }),
++ });
++ }
++ configRoutes.handle(req, res, next);
++});
++
// ── R2 policy and decision log (read-only) ──────────────────
function _decisionPublic(r) {
const parse = (v) => { try { return JSON.parse(v || '{}'); } catch { return {}; } };
ddiff --git a/server/vod/tier-config.js b/server/vod/tier-config.js
nnew file mode 100644
iindex 0000000..ea5ef7b
---- /dev/null
++++ b/server/vod/tier-config.js
@@@ -0,0 +1,100 @@
++/**
++ * OpenVibe.Media — the storage-tier policy as revisioned configuration (roadmap WS-C task 7).
++ *
++ * The thresholds vod-storage.js tiers by (DEFAULTS there: offload ages, disk pressure, R2 popularity…)
++ * live in the namespace `media.storage_tier` of openvibe-shared/config: every change is a revision with
++ * who and why, it is validated (types from the defaults, ranges, and the order the thresholds need) before
++ * it applies, the last good revision is kept and restored when applying fails, and history and rollback
++ * come with it (GET/POST /api/v1/:app/admin/config…, admin routes).
++ *
++ * Revision 1 is what media_settings held (storage_tier.* rows), or the defaults. Every activation still
++ * writes the values through to media_settings, so a release that reads those rows (a rollback within the
++ * supported window) sees the same policy; the rows go in the ADR-028 contract step. Activation then
++ * restarts the tiering sweep if it is running, as the old PUT /tiers/settings did.
++ */
++'use strict';
++const config = require('openvibe-shared/config');
++const db = require('../db/database');
++
++let store = null;
++let onApplied = null; // vod-storage: restart the sweep when it is running
++
++const PCT = new Set(['hotDiskPressurePct', 'localLowWaterPct', 'criticalDiskPct']);
++const MS_FLOOR = { sweepIntervalMs: 60000, pressureRetryMs: 10000, uploadTimeoutFloorMs: 60000, alertCooldownMs: 60000 };
++
++/** A JSON Schema for the defaults: each key's type, non-negative numbers, percentages 1–100, sane intervals. */
++function schemaFor(defaults) {
++ const properties = {};
++ for (const [k, v] of Object.entries(defaults)) {
++ if (typeof v === 'boolean') properties[k] = { type: 'boolean' };
++ else if (typeof v === 'number') {
++ properties[k] = { type: 'number', minimum: MS_FLOOR[k] || 0 };
++ if (PCT.has(k)) Object.assign(properties[k], { minimum: 1, maximum: 100 });
++ } else properties[k] = { type: typeof v };
++ }
++ return { type: 'object', additionalProperties: false, properties };
++}
++
++/** Rules across keys: the drain stops below where it starts, and the free-space budget is ordered. */
++function validate(v) {
++ const errors = [];
++ if (v.localLowWaterPct >= v.hotDiskPressurePct) errors.push('localLowWaterPct must be below hotDiskPressurePct (the drain stops below where it starts)');
++ if (v.hotDiskPressurePct > v.criticalDiskPct) errors.push('hotDiskPressurePct must not be above criticalDiskPct');
++ if (v.minFreeGb > v.targetFreeGb) errors.push('minFreeGb must not be above targetFreeGb');
++ return errors.length ? errors : true;
++}
++
++function legacyRows() {
++ const rows = db.all("SELECT key, value FROM media_settings WHERE key LIKE 'storage_tier.%'") || [];
++ if (!rows.length) return {}; // nothing overridden: revision 1 sets nothing (the defaults apply)
++ return config.fromRows(rows.map((r) => ({ key: r.key, value: r.value, type: 'json' })), { prefix: 'storage_tier.' });
++}
++
++/** media_settings mirrors the active revision exactly: its explicit keys as rows, every other storage_tier.* row gone. */
++function writeThrough(values, defaults) {
++ const h = db.getDb();
++ const up = h.prepare('INSERT INTO media_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
++ const del = h.prepare('DELETE FROM media_settings WHERE key = ?');
++ h.transaction(() => {
++ for (const k of Object.keys(defaults)) {
++ if (values[k] !== undefined) up.run(`storage_tier.${k}`, JSON.stringify(values[k]));
++ else del.run(`storage_tier.${k}`);
++ }
++ })();
++}
++
++/** The store (created on first use, once the database is open). */
++function get(defaults) {
++ if (store) return store;
++ const known = Object.keys(defaults);
++ store = config.createConfigStore({
++ db: db.getDb(), service: 'media', namespace: 'media.storage_tier',
++ defaults, schema: schemaFor(defaults), validate,
++ // Stored revisions only carry known keys: a key a later release dropped is ignored, not refused.
++ // Revision 1 holds exactly what was overridden, so "set explicitly" stays distinguishable from a default.
++ legacy: () => { const l = legacyRows(); const out = {}; for (const k of known) if (l[k] !== undefined) out[k] = l[k]; return out; },
++ onActivate: async (values, _previous, { revision }) => {
++ writeThrough(explicitValues(revision, values), defaults);
++ if (onApplied) onApplied(values);
++ },
++ log: { info: (m) => console.log(`[Tiers] ${m}`), warn: (m) => console.warn(`[Tiers] ${m}`), error: (m) => console.error(`[Tiers] ${m}`) },
++ });
++ return store;
++}
++
++/** The values a revision sets explicitly (written through to media_settings; defaults stay rowless). */
++function explicitValues(revision, effective) {
++ const snap = store && revision != null ? store.snapshot(revision) : null;
++ const out = {};
++ for (const k of Object.keys((snap && snap.values) || {})) out[k] = effective[k];
++ return out;
++}
++
++/** Which keys the active revision sets explicitly (anything else is its default). */
++function explicitKeys() {
++ if (!store || store.revision() == null) return new Set();
++ const snap = store.snapshot(store.revision());
++ return new Set(Object.keys((snap && snap.values) || {}));
++}
++
++module.exports = { get, explicitKeys, schemaFor, validate, setOnApplied: (fn) => { onApplied = fn; }, _reset: () => { store = null; } };
ddiff --git a/server/vod/vod-storage.js b/server/vod/vod-storage.js
iindex 186c03c..9ebcc49 100644
---- a/server/vod/vod-storage.js
++++ b/server/vod/vod-storage.js
@@@ -116,11 +116,11 @@ let sweepRunning = false;
const clients = {}; // providerName → S3Client
const providerHealthy = {}; // providerName → bool
++// The policy is revisioned configuration (server/vod/tier-config.js, openvibe-shared/config): read from
++// memory, changed only through a validated revision (setSettings, the admin config routes).
++const tierConfig = require('./tier-config');
function getSetting(key) {
-- try {
-- const row = db.get('SELECT value FROM media_settings WHERE key = ?', [`storage_tier.${key}`]);
-- if (row) return JSON.parse(row.value);
-- } catch { /* use default */ }
++ try { const v = tierConfig.get(DEFAULTS).get(key); if (v !== undefined) return v; } catch (err) { console.warn('[Tiers] config unavailable:', err.message); }
return DEFAULTS[key];
}
@@@ -130,17 +130,14 @@ function getSettings() {
return s;
}
--function setSetting(key, value) {
-- if (!(key in DEFAULTS)) return;
-- const dbKey = `storage_tier.${key}`;
-- const existing = db.get('SELECT key FROM media_settings WHERE key = ?', [dbKey]);
-- if (existing) db.run('UPDATE media_settings SET value = ? WHERE key = ?', [JSON.stringify(value), dbKey]);
-- else db.run('INSERT INTO media_settings (key, value) VALUES (?, ?)', [dbKey, JSON.stringify(value)]);
++/** Change some settings: one validated revision merged over the active one. → the new snapshot (throws ConfigError 422/409) */
++function setSettings(updates, { actor = null, reason = null } = {}) {
++ return tierConfig.get(DEFAULTS).apply(updates, { merge: true, actor, reason });
}
--/** Is this storage_tier.* setting overridden in media_settings? ('setting') or the built-in default ('default'). */
++/** Does the active revision set this key explicitly ('setting'), or is it the built-in default ('default')? */
function settingSource(key) {
-- try { return db.get('SELECT 1 AS x FROM media_settings WHERE key = ?', [`storage_tier.${key}`]) ? 'setting' : 'default'; } catch { return 'default'; }
++ try { tierConfig.get(DEFAULTS); return tierConfig.explicitKeys().has(key) ? 'setting' : 'default'; } catch { return 'default'; }
}
// r2Enabled, r2MinViews, r2RecentAccessDays, r2MaxIdleDays, r2MaxPerSweep
@@@ -1195,6 +1192,9 @@ function scheduleNext(delayMs) {
if (sweepTimer.unref) sweepTimer.unref();
}
++// A new policy revision restarts a running sweep with it (as PUT /tiers/settings always did).
++tierConfig.setOnApplied(() => { if (sweepTimer) { stop(); start(); } });
++
function start() {
stop();
const settings = getSettings();
@@@ -1437,7 +1437,8 @@ module.exports = {
getBucketUsage,
estimateCloudCosts,
getSettings,
-- setSetting,
++ setSettings,
++ tierConfig,
diskUsage,
dirStats,
};
ddiff --git a/test/tier-config.test.js b/test/tier-config.test.js
nnew file mode 100644
iindex 0000000..eedc818
---- /dev/null
++++ b/test/tier-config.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++// The storage-tier policy as revisioned configuration (WS-C task 7; server/vod/tier-config.js on
++// openvibe-shared/config): revision 1 is what media_settings held, PUT /tiers/settings is one validated
++// revision (the rules across thresholds included; a refused change changes nothing), media_settings mirrors
++// the active revision for older releases, the admin config routes list, show history and roll back, and
++// a policy change never reads the database per setting.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-tiercfg-'));
++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++Object.assign(process.env, {
++ DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
++ THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'),
++});
++
++(async () => {
++ const db = require('../server/db/database');
++ db.upsertApp({ app_id: 'live', api_key: 'live-key-tiercfg' });
++ // An operator had overridden one threshold before this release.
++ db.run("INSERT INTO media_settings (key, value) VALUES ('storage_tier.minAgeDays', '10')");
++ const storage = require('../server/vod/vod-storage');
++ const row = (k) => { const r = db.get('SELECT value FROM media_settings WHERE key = ?', [`storage_tier.${k}`]); return r ? JSON.parse(r.value) : undefined; };
++
++ // ── Revision 1: exactly what media_settings held ──
++ assert.strictEqual(storage.getSettings().minAgeDays, 10, 'the override carries over');
++ assert.strictEqual(storage.getSettings().maxViewsForCold, storage.DEFAULTS.maxViewsForCold, 'everything else is its default');
++ const cfg = storage.tierConfig.get(storage.DEFAULTS);
++ assert.strictEqual(cfg.revision(), 1);
++ assert.deepStrictEqual(cfg.snapshot(1).values, { minAgeDays: 10 });
++
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));
++ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const call = async (method, p, body) => {
++ const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { authorization: 'Bearer live-key-tiercfg', 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
++ return { status: res.status, body: await res.json().catch(() => null) };
++ };
++
++ // ── PUT /tiers/settings: one validated revision, merged, recorded with the app and the reason ──
++ let r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', { r2MinViews: '30', r2Enabled: 'false', reason: 'fewer promotions' });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.revision, 2);
++ assert.deepStrictEqual([r.body.settings.r2MinViews, r.body.settings.r2Enabled, r.body.settings.minAgeDays], [30, false, 10], 'coerced, and merged over the active revision');
++ assert.deepStrictEqual([row('r2MinViews'), row('r2Enabled'), row('minAgeDays'), row('maxViewsForCold')], [30, false, 10, undefined], 'media_settings mirrors the revision (defaults stay rowless)');
++
++ // A change that breaks a rule across thresholds is refused whole, and nothing changes.
++ r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', { localLowWaterPct: 80, r2MinViews: 40 });
++ assert.strictEqual(r.status, 422, JSON.stringify(r.body));
++ assert.strictEqual(r.body.code, 'config.invalid');
++ assert.ok(JSON.stringify(r.body.errors).includes('localLowWaterPct must be below hotDiskPressurePct'), JSON.stringify(r.body.errors));
++ assert.deepStrictEqual([cfg.revision(), storage.getSettings().r2MinViews, row('r2MinViews')], [2, 30, 30]);
++ r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', { hotDiskPressurePct: 150 });
++ assert.strictEqual(r.status, 422, 'a percentage above 100');
++ r = await call('PUT', '/api/v1/live/admin/storage/tiers/settings', {});
++ assert.strictEqual(r.status, 400, 'nothing to change');
++
++ // The policy endpoint says where each threshold comes from.
++ r = await call('GET', '/api/v1/live/admin/storage/tiers/policy');
++ assert.deepStrictEqual(r.body.r2.thresholds.r2MinViews, { value: 30, default: storage.DEFAULTS.r2MinViews, source: 'setting' });
++ assert.strictEqual(r.body.r2.thresholds.r2MaxIdleDays.source, 'default');
++
++ // ── The configuration model's routes: list, history, rollback ──
++ r = await call('GET', '/api/v1/live/admin/storage/config');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ const ns = r.body.namespaces.find((n) => n.namespace === 'media.storage_tier');
++ assert.deepStrictEqual([ns.revision, ns.last_known_good && ns.last_known_good.revision], [2, 2]);
++ r = await call('GET', '/api/v1/live/admin/storage/config/media.storage_tier/history');
++ assert.deepStrictEqual(r.body.snapshots.map((x) => [x.revision, x.state]), [[2, 'active'], [1, 'superseded']]);
++ assert.deepStrictEqual(r.body.snapshots[0].created_by, { type: 'service', id: 'live' });
++ assert.strictEqual(r.body.snapshots[0].reason, 'fewer promotions');
++ r = await call('POST', '/api/v1/live/admin/storage/config/media.storage_tier/rollback', { reason: 'back to what it was' });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.revision, 3);
++ assert.deepStrictEqual([storage.getSettings().r2MinViews, storage.getSettings().r2Enabled, storage.getSettings().minAgeDays], [storage.DEFAULTS.r2MinViews, true, 10], 'revision 1 again, as a new revision');
++ assert.deepStrictEqual([row('r2MinViews'), row('r2Enabled'), row('minAgeDays')], [undefined, undefined, 10], 'and media_settings with it');
++ assert.strictEqual((await call('GET', '/api/v1/live/admin/storage/config/other.namespace')).status, 404);
++
++ // ── A restart reads the same active revision ──
++ storage.tierConfig._reset();
++ assert.deepStrictEqual([storage.tierConfig.get(storage.DEFAULTS).revision(), storage.getSettings().minAgeDays], [3, 10]);
++
++ server.close();
++ console.log('tier config: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/tier-decisions.test.js b/test/tier-decisions.test.js
iindex e533dd2..1438832 100644
---- a/test/tier-decisions.test.js
++++ b/test/tier-decisions.test.js
@@@ -78,9 +78,8 @@ const s3 = http.createServer((req, res) => {
assert.deepStrictEqual([storage.DEFAULTS.r2Enabled, storage.DEFAULTS.r2MinViews, storage.DEFAULTS.r2RecentAccessDays, storage.DEFAULTS.r2MaxIdleDays, storage.DEFAULTS.r2MaxPerSweep],
[true, 20, 3, 14, 5]);
// Keep the sweep's disk-pressure drain out of this test (whatever this machine's disk looks like).
-- storage.setSetting('hotDiskPressurePct', 101);
-- storage.setSetting('criticalDiskPct', 101);
-- storage.setSetting('minFreeGb', 0);
++ // One validated revision of media.storage_tier (percentages are 1–100: at 100 the drain never starts).
++ await storage.setSettings({ hotDiskPressurePct: 100, criticalDiskPct: 100, minFreeGb: 0 }, { reason: 'test: no pressure drain' });
const bytes = crypto.randomBytes(200000);
const addVod = (id, { app = 'live', provider = 'local', views = 0, accessed = null } = {}) => {