openvibers/openvibe.vip Commits

The SQLite era is gone (on PostgreSQL since 2026-09-28; no compatibility kept): the one-time migrate-to-postgres script, the *_DB_PATH settings (config, .env.example, systemd unit, test helpers), the unused better-sqlite3 dependency, and docs, docstrings and STATUS lines that still described SQLite or its rollback window. Reviewed independently before shipping. npm test 10/10 test files passed.

OP OpenVibers committed 29 Sept 2026 · 5a6b392c17 · 4 files, +0 −515


Changes

ddiff --git a/scripts/import-live.js b/scripts/import-live.js
ddeleted file mode 100644
iindex 97565e0..0000000
---- a/scripts/import-live.js
++++ /dev/null
@@@ -1,62 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * Import OpenVibe.Live's subscription offering into VIP (creators, the network subscriber-badge perk,
-- * one "Channel subscription" plan per creator) and file OpenVibe.Billing's active subscriptions as
-- * memberships under it. Live's own subscription rows are reconciled, never used as membership truth.
-- *
-- * node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]
-- *
-- * The Live database must be a COPY (`sqlite3 live.db ".backup live-snapshot.db"`); it is opened
-- * read-only. Run it AFTER Billing's Live import. Memberships come from Billing's API with VIP's
-- * client credentials (billing.entitlement.check), or from a read-only Billing snapshot with
-- * --billing-db. Live user ids are resolved through the Network (identity.subject.resolve).
-- * Exit code 1 when Billing could not be read for some creator (the report lists them). Safe to re-run.
-- */
--const path = require('path');
--const Database = require('better-sqlite3');
--const { loadConfig } = require('../server/config');
--const { openDb } = require('../server/db');
--const { createIdentity } = require('../server/network');
--const { createBillingClient } = require('../server/billing-client');
--const { createVipOutbox } = require('../server/events/outbox');
--const { createDomain } = require('../server/domain');
--const { importLive, billingApiSource, billingSnapshotSource } = require('../server/importer/live');
--
--const args = process.argv.slice(2);
--const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
--const flag = (name) => args.includes(`--${name}`);
--
--async function main() {
-- const livePath = opt('live-db');
-- if (!livePath) { console.error('usage: import-live.js --live-db <snapshot> [--billing-db <snapshot>] [--dry-run] [--json]'); process.exit(2); }
-- const config = loadConfig();
-- for (const p of [livePath, opt('billing-db')].filter(Boolean)) if (path.resolve(p) === path.resolve(config.dbPath)) throw new Error(`${p} is the VIP database`);
-- const live = new Database(livePath, { readonly: true, fileMustExist: true });
-- const bdb = opt('billing-db') ? new Database(opt('billing-db'), { readonly: true, fileMustExist: true }) : null;
-- const db = await openDb(config); // PostgreSQL (ADR-035): DATABASE_URL, or the development PGlite
-- const billing = createBillingClient(config);
-- const outbox = createVipOutbox({ db, config });
-- const domain = createDomain({ db, config, outbox, billing });
-- const identity = createIdentity(config);
-- const report = await importLive(domain, {
-- live, billingSource: bdb ? billingSnapshotSource(bdb) : billingApiSource(billing), resolveLiveUsers: identity.resolveLiveUsers, dryRun: flag('dry-run'),
-- });
-- if (flag('json')) console.log(JSON.stringify(report, null, 2));
-- else {
-- const c = report.counts;
-- console.log(`import ${report.run_id}${report.dry_run ? ' (DRY RUN — nothing kept)' : ''} · billing source: ${report.billing_source}`);
-- console.log(` live snapshot: ${JSON.stringify(c.live)}; offering ${JSON.stringify(report.live_offering)}`);
-- console.log(` creators ${JSON.stringify(c.creators)}; plans ${JSON.stringify(c.plans)}`);
-- console.log(` memberships ${JSON.stringify(c.memberships)}; live subscriptions ${JSON.stringify(c.live_subscriptions)}`);
-- for (const h of report.holds) console.log(` held: ${JSON.stringify(h)}`);
-- for (const x of report.excluded) console.log(` excluded: ${JSON.stringify(x)}`);
-- for (const e of report.billing_errors) console.log(` billing error: ${JSON.stringify(e)}`);
-- }
-- live.close();
-- if (bdb) bdb.close();
-- db.close();
-- if (!report.ok) process.exit(1);
--}
--
--main().catch((err) => { console.error(`import failed: ${err.message}`); process.exit(1); });
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
ddeleted file mode 100644
iindex 2222910..0000000
---- a/scripts/migrate-to-postgres.js
++++ /dev/null
@@@ -1,26 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * The one-time move of VIP's SQLite database (VIP_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is
-- * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
-- *
-- * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
-- *
-- * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts
-- * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its
-- * name and columns.
-- */
--require('dotenv').config();
--const { runSqliteMigration } = require('openvibe-sdk/db');
--const configLib = require('../server/config');
--const { MIGRATIONS } = require('../server/db');
--
--const TABLES = {};
--
--if (require.main === module) {
-- const config = configLib.loadConfig();
-- runSqliteMigration({ service: 'vip', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })
-- .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
--}
--
--module.exports = { TABLES };
ddiff --git a/server/importer/live.js b/server/importer/live.js
ddeleted file mode 100644
iindex 84469c5..0000000
---- a/server/importer/live.js
++++ /dev/null
@@@ -1,270 +0,0 @@
--'use strict';
--
--/**
-- * Import OpenVibe.Live's subscription OFFERING into VIP, from a SNAPSHOT COPY of Live's database
-- * (opened read-only). Memberships come from OpenVibe.Billing's entitlements (Billing already
-- * imported Live's subscriptions, ADR-012), never from Live's rows.
-- *
-- * What Live offers today: every channel can be subscribed to, monthly, at the site-wide price in
-- * site_settings (sub_price_usd, now Billing's rate), and a subscriber gets a star "Subscriber" badge
-- * (Live chat, the PowerChat overlay relay, the stream AI's context flag). So:
-- *
-- * 1. perk one network perk `subscriber-badge` with product bindings for those three places
-- * and OpenVibe.Chat's badge
-- * 2. creators every Live user who is a streamer (role streamer / global_mod / admin) or has ever
-- * been subscribed to → a VIP creator for their Network subject (resolve-batch). A Live
-- * user with no subject yet is HELD (vip_migration_maps), never dropped; a later run
-- * picks it up
-- * 3. plans per creator, plan `channel-subscription` v1 "Channel subscription" (published, sold
-- * as Billing's channel_subscription) with the badge perk — unless the creator already
-- * has a published plan sold that way (then that plan is linked, nothing is created)
-- * 4. members per creator, Billing's subscriptions (billing.entitlement.check): each ACTIVE one
-- * becomes a VIP membership under the plan version above and a projection row from
-- * Billing's entitlement; inactive ones are recorded excluded with the reason
-- * 5. live rows every row of Live's `subscriptions` table is reconciled: linked to the VIP
-- * membership / Billing subscription for the same pair, or held with the reason
-- *
-- * Idempotent: every source row is keyed in vip_migration_maps; a re-run creates nothing twice,
-- * never edits a plan a creator has changed since, and releases what was held when it can.
-- * --dry-run does everything inside a transaction that is rolled back.
-- */
--const { iso } = require('../util');
--
--const tableExists = async (db, name) => !!await db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
--const columns = async (db, t) => (await tableExists(db, t) ? (await db.prepare(`PRAGMA table_info(${t})`).all()).map((c) => c.name) : []);
--const CREATOR_ROLES = ['streamer', 'global_mod', 'admin'];
--const PLAN_SLUG = 'channel-subscription';
--const PERK_KEY = 'subscriber-badge';
--const ACTOR = 'svc:vip-import';
--
--class DryRun extends Error {}
--
--/** Billing through its API (VIP's service token, billing.entitlement.check). */
--function billingApiSource(billing) {
-- return {
-- name: 'billing api',
-- listSubscriptions: async (creator) => ((await billing.listSubscriptions({ streamer: creator })).subscriptions || []),
-- entitlement: (member, creator) => billing.entitlement(member, creator),
-- };
--}
--
--/** Billing from a read-only SNAPSHOT of billing.db (operator tool, for dry runs without a token). */
--function billingSnapshotSource(bdb, { now = () => Date.now() } = {}) {
-- const subs = bdb.prepare('SELECT * FROM subscriptions WHERE streamer = ? ORDER BY created_at');
-- return {
-- name: 'billing snapshot',
-- listSubscriptions: async (creator) => (await subs.all(creator)).map((s) => ({
-- id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, status: s.status,
-- auto_renew: !!s.auto_renew, cancel_at_period_end: !!s.cancel_at_period_end, current_period_end: s.current_period_end, legacy_live_id: s.legacy_live_id ?? null,
-- })),
-- entitlement: async (member, creator) => {
-- const at = iso(now());
-- const rows = await bdb.prepare(`SELECT starts_at, ends_at FROM entitlements WHERE subject = ? AND kind = 'channel_subscription' AND scope = ?
-- AND revoked_at IS NULL AND ends_at > ? ORDER BY starts_at`).all(member, creator, at);
-- const active = rows.some((r) => r.starts_at <= at);
-- const sub = await bdb.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(member, creator);
-- return {
-- active, expires_at: active ? rows.reduce((m, r) => (r.ends_at > m ? r.ends_at : m), '') : null,
-- subscription: sub ? { id: sub.id, status: sub.status, auto_renew: !!sub.auto_renew, cancel_at_period_end: !!sub.cancel_at_period_end } : null,
-- };
-- },
-- };
--}
--
--async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRun = false, log = console }) {
-- const { db, creators, perks, plans, entitlements } = domain;
-- const started = domain.now();
-- const runId = `vimp_${started}`;
-- const at = iso(started);
-- const report = {
-- run_id: runId, dry_run: !!dryRun, started_at: at, source: 'openvibe-live snapshot', billing_source: billingSource ? billingSource.name : null,
-- live_offering: null,
-- counts: {
-- creators: { imported: 0, unchanged: 0, held: 0, released: 0 }, plans: { created: 0, linked: 0, unchanged: 0 },
-- memberships: { imported: 0, unchanged: 0, excluded: 0 }, live_subscriptions: { linked: 0, held: 0, unchanged: 0 },
-- },
-- holds: [], excluded: [], billing_errors: [],
-- };
--
-- // ── Read the snapshot ────────────────────────────────────
-- const hasUsers = await tableExists(live, 'users');
-- const userCols = await columns(live, 'users');
-- const users = hasUsers ? await live.prepare(`SELECT id, username, display_name${userCols.includes('role') ? ', role' : ", 'user' AS role"} FROM users`).all() : [];
-- const byLiveId = new Map(users.map((u) => [String(u.id), u]));
-- const subCols = await columns(live, 'subscriptions');
-- const liveSubs = subCols.length ? await live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
-- if (await tableExists(live, 'site_settings')) {
-- const get = async (k) => { const r = await live.prepare('SELECT value FROM site_settings WHERE key = ?').get(k); return r ? r.value : null; };
-- report.live_offering = {
-- sub_price_usd: await get('sub_price_usd'), sub_streamer_share_pct: await get('sub_streamer_share_pct'), sub_site_route_fee_pct: await get('sub_site_route_fee_pct'),
-- note: 'recorded for reference only: OpenVibe.Billing prices and charges subscriptions; VIP plan terms carry no price',
-- };
-- }
-- const creatorIds = new Set(users.filter((u) => CREATOR_ROLES.includes(u.role)).map((u) => String(u.id)));
-- for (const s of liveSubs) if (s.streamer_id != null) creatorIds.add(String(s.streamer_id));
-- const allIds = new Set(creatorIds);
-- for (const s of liveSubs) if (s.subscriber_id != null) allIds.add(String(s.subscriber_id));
-- report.counts.live = { users: users.length, creators: creatorIds.size, subscriptions: liveSubs.length };
--
-- // ── Identities ───────────────────────────────────────────
-- const map = allIds.size ? await resolveLiveUsers([...allIds]) : new Map();
-- const subjectOf = (id) => { const v = id == null ? null : map.get(String(id)); return v ? (typeof v === 'string' ? v : v.subject) : null; };
-- const handleOf = (id) => { const v = map.get(String(id)); const u = byLiveId.get(String(id)); return (v && typeof v === 'object' && v.username) || (u && u.username) || null; };
--
-- // ── Billing (async, before the transaction) ──────────────
-- const billingSubs = new Map(); // creator subject → [subscription]
-- const ents = new Map(); // `${member}:${creator}` → entitlement
-- for (const id of creatorIds) {
-- const s = subjectOf(id);
-- if (!s || billingSubs.has(s)) continue;
-- if (!billingSource) { billingSubs.set(s, null); continue; }
-- try {
-- const list = await billingSource.listSubscriptions(s);
-- billingSubs.set(s, list);
-- for (const sub of list) {
-- if (sub.status !== 'active') continue;
-- ents.set(`${sub.subscriber.id}:${s}`, await billingSource.entitlement(sub.subscriber.id, s));
-- }
-- } catch (e) {
-- billingSubs.set(s, null);
-- report.billing_errors.push({ creator: s, error: e.message });
-- }
-- }
--
-- const mapRow = db.prepare('SELECT * FROM vip_migration_maps WHERE source = ? AND source_table = ? AND source_id = ?');
-- const upsertMap = db.prepare(`INSERT INTO vip_migration_maps (source, source_table, source_id, target_type, target_id, status, reason, run_id, created_at, updated_at)
-- VALUES (@source, @table, @id, @target_type, @target_id, @status, @reason, @run, @at, @at)
-- ON CONFLICT (source, source_table, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id, status = excluded.status,
-- reason = excluded.reason, run_id = excluded.run_id, updated_at = excluded.updated_at`);
-- const record = async (source, table, id, fields) => await upsertMap.run({ source, table, id: String(id), target_type: null, target_id: null, reason: null, run: runId, at, ...fields });
--
-- const run = async () => {
-- // ── 1. The network perk ──────────────────────────────
-- let perk = await perks.byKey('network', PERK_KEY);
-- if (!perk) {
-- perk = await perks.create({
-- creatorId: 'network', key: PERK_KEY, name: 'Subscriber badge', kind: 'badge', actor: ACTOR,
-- description: 'A star badge next to your name in the creator\'s chat, as channel subscribers had on OpenVibe.Live.',
-- bindings: [
-- { product: 'live', binding: 'chat_badge', config: { badge: 'subscriber' } },
-- { product: 'live', binding: 'powerchat_overlay', config: { is_subscriber: true } },
-- { product: 'live', binding: 'ai_context', config: { flag: 'subscriber' } },
-- { product: 'chat', binding: 'badge', config: { badge: 'subscriber' } },
-- ],
-- });
-- await record('live', 'offering', 'subscriber_badge', { target_type: 'perk', target_id: perk.id, status: 'imported' });
-- }
--
-- // ── 2 + 3. Creators and their plan ───────────────────
-- for (const id of [...creatorIds].sort((a, b) => Number(a) - Number(b))) {
-- const prev = await mapRow.get('live', 'users', id);
-- const subject = subjectOf(id);
-- const u = byLiveId.get(id);
-- if (!subject) {
-- const reason = `Live user ${id}${u ? ` (${u.username})` : ''} has no Network subject yet`;
-- await record('live', 'users', id, { status: 'held', reason });
-- report.counts.creators.held++; report.holds.push({ live_user: Number(id), reason });
-- continue;
-- }
-- const existed = await creators.bySubject(subject);
-- const c = await creators.ensure({ subject, username: handleOf(id), displayName: u ? u.display_name : null, origin: existed ? existed.origin : 'import' });
-- if (prev && prev.status !== 'held') report.counts.creators.unchanged++;
-- else { report.counts.creators[prev ? 'released' : 'imported']++; }
-- await record('live', 'users', id, { target_type: 'creator', target_id: c.id, status: 'imported', reason: prev && prev.status === 'held' ? 'released from hold' : null });
--
-- const offer = await mapRow.get('live', 'offering', `channel_subscription:${id}`);
-- if (offer && offer.target_id && await plans.byId(offer.target_id)) { report.counts.plans.unchanged++; continue; }
-- const own = await plans.purchasable(c.id);
-- if (own) {
-- await record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: own.id, status: 'linked', reason: 'the creator already publishes a plan sold as the channel subscription' });
-- report.counts.plans.linked++;
-- continue;
-- }
-- const existingSlug = await db.prepare('SELECT * FROM vip_plans WHERE creator_id = ? AND slug = ?').get(c.id, PLAN_SLUG);
-- if (existingSlug) {
-- await record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: existingSlug.id, status: 'linked', reason: `plan ${PLAN_SLUG} already exists (status ${existingSlug.status})` });
-- report.counts.plans.linked++;
-- continue;
-- }
-- const name = c.display_name || c.username || 'this creator';
-- const plan = await plans.create({
-- creatorId: c.id, slug: PLAN_SLUG, name: 'Channel subscription',
-- description: `A monthly subscription to ${name}'s channel, carried over from OpenVibe.Live.`,
-- benefits: [`A subscriber badge next to your name in ${name}'s chat`],
-- perks: [PERK_KEY], billingKind: 'channel_subscription', publish: true, changeNote: 'imported from OpenVibe.Live', actor: ACTOR,
-- });
-- await record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: plan.id, status: 'imported' });
-- report.counts.plans.created++;
-- }
--
-- // ── 4. Memberships from Billing ──────────────────────
-- for (const [creatorSubject, list] of billingSubs) {
-- if (!list) continue;
-- for (const sub of list) {
-- const member = sub.subscriber && sub.subscriber.id;
-- const prev = await mapRow.get('billing', 'subscriptions', sub.id);
-- if (sub.status !== 'active') {
-- if (!prev) {
-- const reason = `Billing subscription is ${sub.status}: no current membership to file terms for`;
-- await record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });
-- report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });
-- } else report.counts.memberships.unchanged++;
-- continue;
-- }
-- const e = ents.get(`${member}:${creatorSubject}`) || { active: false, expires_at: null };
-- const out = await entitlements.apply({
-- member, creator: creatorSubject, active: !!e.active, expiresAt: e.expires_at || null,
-- cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id, subscriptionStatus: sub.status,
-- reason: 'imported', source: 'import', origin: 'import', asOf: domain.now(),
-- });
-- const m = out.membership;
-- if (!e.active || !m) {
-- const reason = 'Billing lists the subscription as active but its entitlement is not active now';
-- await record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });
-- report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });
-- continue;
-- }
-- if (prev && prev.status === 'imported' && prev.target_id === m.id) report.counts.memberships.unchanged++;
-- else report.counts.memberships.imported++;
-- await record('billing', 'subscriptions', sub.id, { target_type: 'membership', target_id: m.id, status: 'imported' });
-- }
-- }
--
-- // ── 5. Live's subscriptions rows ─────────────────────
-- const membershipFor = db.prepare('SELECT m.id FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?');
-- for (const s of liveSubs) {
-- const prev = await mapRow.get('live', 'subscriptions', String(s.id));
-- const member = subjectOf(s.subscriber_id);
-- const creator = subjectOf(s.streamer_id);
-- let fields;
-- if (!member || !creator) {
-- fields = { status: 'held', reason: `${!member ? `subscriber (Live user ${s.subscriber_id})` : `creator (Live user ${s.streamer_id})`} has no Network subject yet` };
-- } else {
-- const m = await membershipFor.get(member, creator);
-- const bsub = (billingSubs.get(creator) || []).find((x) => x.subscriber && x.subscriber.id === member);
-- if (m) fields = { status: 'linked', target_type: 'membership', target_id: m.id };
-- else if (bsub) fields = { status: 'linked', target_type: 'billing_subscription', target_id: bsub.id, reason: `Billing holds it (status ${bsub.status}); no current membership` };
-- else if (billingSubs.get(creator) === null) fields = { status: 'held', reason: 'Billing was not reachable for this creator in this run' };
-- else fields = { status: 'held', reason: 'Billing has no subscription for this pair (run Billing\'s Live import first)' };
-- }
-- if (prev && prev.status === fields.status && prev.target_id === (fields.target_id || null)) { report.counts.live_subscriptions.unchanged++; continue; }
-- await record('live', 'subscriptions', s.id, fields);
-- report.counts.live_subscriptions[fields.status === 'held' ? 'held' : 'linked']++;
-- if (fields.status === 'held') report.holds.push({ live_subscription: s.id, reason: fields.reason });
-- }
-- if (dryRun) throw new DryRun();
-- };
--
-- try {
-- await db.tx(run);
-- } catch (e) {
-- if (!(e instanceof DryRun)) throw e;
-- }
-- report.finished_at = iso(domain.now());
-- report.ok = report.billing_errors.length === 0;
-- if (!billingSource) report.note = 'no Billing source: memberships were not imported';
-- if (log && log.debug) log.debug(report);
-- return report;
--}
--
--module.exports = { importLive, billingApiSource, billingSnapshotSource, PLAN_SLUG, PERK_KEY };
ddiff --git a/test/import.test.js b/test/import.test.js
ddeleted file mode 100644
iindex aea8cf5..0000000
---- a/test/import.test.js
++++ /dev/null
@@@ -1,157 +0,0 @@
--'use strict';
--/**
-- * scripts/import-live.js: Live's subscription offering → creators, the subscriber-badge perk and a
-- * v1 "Channel subscription" plan; memberships from Billing (never from Live's rows); every Live row
-- * reconciled; idempotent; holds released on a later run; dry run keeps nothing.
-- */
--const assert = require('assert');
--const fs = require('fs');
--const os = require('os');
--const path = require('path');
--const Database = require('better-sqlite3');
--const { boot, harness } = require('./helpers/app');
--
--const { test, run } = harness('import');
--
--function liveSnapshot() {
-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vip-live-'));
-- const file = path.join(dir, 'live.db');
-- const db = new Database(file);
-- db.exec(`
-- CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, display_name TEXT, role TEXT DEFAULT 'user');
-- CREATE TABLE subscriptions (id INTEGER PRIMARY KEY, subscriber_id INTEGER, streamer_id INTEGER, tier INTEGER DEFAULT 1, is_active INTEGER,
-- started_at TEXT, expires_at TEXT, status TEXT, current_period_end TEXT, provider TEXT);
-- CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT);
-- INSERT INTO site_settings VALUES ('sub_price_usd', '4.99'), ('sub_streamer_share_pct', '70'), ('sub_site_route_fee_pct', '10');
-- INSERT INTO users VALUES (1, 'japaneseoldguy', 'Japanese Old Guy', 'streamer'), (2, 'viewer1', 'Viewer One', 'user'),
-- (3, 'viewer2', NULL, 'user'), (4, 'newstreamer', NULL, 'streamer'), (5, 'casual', NULL, 'user'), (6, 'selfmade', 'Self Made', 'streamer');
-- INSERT INTO subscriptions VALUES (1, 2, 1, 1, 1, '2026-09-01 10:00:00', NULL, 'active', '2026-10-01 10:00:00', 'bucks'),
-- (2, 3, 1, 1, 0, '2026-07-01 10:00:00', NULL, 'expired', '2026-08-01 10:00:00', 'stripe'),
-- (3, 2, 5, 1, 1, '2026-09-10 10:00:00', NULL, 'active', '2026-10-10 10:00:00', 'bucks');
-- `);
-- db.close();
-- return { file, dir };
--}
--
--(async () => {
-- const t = await boot();
-- const { importLive, billingApiSource } = require('../server/importer/live');
-- const { createIdentity } = require('../server/network');
-- const identity = createIdentity(t.config);
-- const snap = liveSnapshot();
-- const live = new Database(snap.file, { readonly: true, fileMustExist: true });
--
-- // Network knows every Live user but #4 (held); Billing imported Live's subscriptions already.
-- const S = {};
-- for (const [id, name] of [[1, 'japaneseoldguy'], [2, 'viewer1'], [3, 'viewer2'], [5, 'casual'], [6, 'selfmade']]) S[id] = t.network.mapLive(id, name);
-- t.billing.pay(S[2], S[1], { legacyLiveId: 1 });
-- const expired = t.billing.pay(S[3], S[1], { legacyLiveId: 2, provider: 'stripe' }).sub;
-- t.billing.expire(expired.id, 'expired');
-- t.billing.pay(S[2], S[5], { legacyLiveId: 3 });
-- // #6 already made their own plan on VIP before the import.
-- const selfmade = { subject: S[6], username: 'selfmade', role: 'streamer' };
-- const own = (await t.call('POST', '/api/v1/plans', { user: selfmade, body: { name: 'My own plan', publish: true } })).json.plan;
--
-- const doImport = (opts = {}) => importLive(t.domain, { live, billingSource: billingApiSource(t.domain.billing), resolveLiveUsers: identity.resolveLiveUsers, log: null, ...opts });
-- const snapshotCounts = async () => Object.fromEntries((await Promise.all(['vip_creators', 'vip_plans', 'vip_plan_versions', 'vip_perks', 'vip_memberships', 'vip_migration_maps', 'vip_entitlement_projection', 'event_outbox']
-- .map(async (x) => [x, (await t.domain.db.prepare(`SELECT COUNT(*) AS n FROM ${x}`).get()).n]))));
--
-- test('a dry run reports everything and keeps nothing', async () => {
-- const before = await snapshotCounts();
-- const r = await doImport({ dryRun: true });
-- assert.strictEqual(r.dry_run, true);
-- assert.strictEqual(r.counts.plans.created, 2);
-- assert.deepStrictEqual(await snapshotCounts(), before);
-- });
--
-- let first;
-- test('the first run imports the offering, files Billing memberships and reconciles every Live row', async () => {
-- first = await doImport();
-- assert.strictEqual(first.ok, true, JSON.stringify(first.billing_errors));
-- assert.strictEqual(first.live_offering.sub_price_usd, '4.99');
-- assert.deepStrictEqual(first.counts.live, { users: 6, creators: 4, subscriptions: 3 });
-- assert.deepStrictEqual(first.counts.creators, { imported: 3, unchanged: 0, held: 1, released: 0 });
-- assert.deepStrictEqual(first.counts.plans, { created: 2, linked: 1, unchanged: 0 });
-- assert.deepStrictEqual(first.counts.memberships, { imported: 2, unchanged: 0, excluded: 1 });
-- assert.deepStrictEqual(first.counts.live_subscriptions, { linked: 3, held: 0, unchanged: 0 });
-- assert.ok(first.holds.some((h) => h.live_user === 4));
--
-- const perk = await t.domain.perks.byKey('network', 'subscriber-badge');
-- assert.deepStrictEqual((await t.domain.perks.bindingsOf(perk.id)).map((b) => `${b.product}:${b.binding}`).sort(), ['chat:badge', 'live:ai_context', 'live:chat_badge', 'live:powerchat_overlay']);
-- const jog = await t.domain.creators.bySubject(S[1]);
-- assert.strictEqual(jog.username, 'japaneseoldguy');
-- assert.strictEqual(jog.display_name, 'Japanese Old Guy');
-- const plan = await t.domain.plans.purchasable(jog.id);
-- assert.strictEqual(plan.slug, 'channel-subscription');
-- assert.strictEqual(plan.latest_version, 1);
-- assert.deepStrictEqual((await t.domain.plans.versionPerks(plan.current_version_id)).map((p) => p.perk_key), ['subscriber-badge']);
-- const terms = JSON.parse((await t.domain.plans.version(plan.current_version_id)).terms);
-- assert.strictEqual(terms.price, 'set and charged by OpenVibe.Billing');
-- assert.doesNotMatch(JSON.stringify(terms), /\bfree\b|\$0|no ads/i);
--
-- const m = await t.domain.memberships.get(S[2], jog.id);
-- assert.strictEqual(m.origin, 'import');
-- assert.strictEqual(m.plan_version_id, plan.current_version_id);
-- assert.strictEqual(await t.domain.memberships.get(S[3], jog.id), null, 'an expired Billing subscription is not a membership');
-- assert.strictEqual((await t.domain.plans.purchasable((await t.domain.creators.bySubject(S[6])).id)).id, own.id, 'the creator\'s own plan is linked, not replaced');
-- const e = await t.call('GET', `/api/v1/entitlements/check?creator=${S[1]}&mode=projection`, { user: { subject: S[2], username: 'viewer1' } });
-- assert.strictEqual(e.json.status, 'active');
--
-- const maps = await t.domain.db.prepare("SELECT status, COUNT(*) AS n FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' GROUP BY status").all();
-- assert.deepStrictEqual(maps, [{ status: 'linked', n: 3 }]);
-- const expiredRow = await t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '2'").get();
-- assert.strictEqual(expiredRow.target_type, 'billing_subscription');
-- assert.strictEqual((await t.outboxEvents('vip.plan.published')).length, 3);
-- });
--
-- test('a second run changes nothing', async () => {
-- const before = await snapshotCounts();
-- const again = await doImport();
-- assert.deepStrictEqual(await snapshotCounts(), before);
-- assert.deepStrictEqual(again.counts.plans, { created: 0, linked: 0, unchanged: 3 });
-- assert.deepStrictEqual(again.counts.memberships, { imported: 0, unchanged: 3, excluded: 0 });
-- assert.deepStrictEqual(again.counts.live_subscriptions, { linked: 0, held: 0, unchanged: 3 });
-- assert.strictEqual(again.counts.creators.held, 1);
-- });
--
-- test('a creator\'s edits after the import are never overwritten', async () => {
-- const jog = await t.domain.creators.bySubject(S[1]);
-- const plan = await t.domain.plans.purchasable(jog.id);
-- await t.domain.plans.update(plan.id, { name: 'Japanese Old Guy club', actor: S[1] });
-- await doImport();
-- assert.strictEqual((await t.domain.plans.latestVersion(plan.id)).version, 2);
-- assert.strictEqual((await t.domain.plans.latestVersion(plan.id)).name, 'Japanese Old Guy club');
-- });
--
-- test('a held creator is released once the Network knows them', async () => {
-- S[4] = t.network.mapLive(4, 'newstreamer');
-- const r = await doImport();
-- assert.strictEqual(r.counts.creators.released, 1);
-- assert.strictEqual(r.counts.creators.held, 0);
-- assert.strictEqual(r.counts.plans.created, 1);
-- assert.strictEqual((await t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'users' AND source_id = '4'").get()).status, 'imported');
-- });
--
-- test('Billing unreachable: the run says so, holds what it could not reconcile and never invents memberships', async () => {
-- const x = t.network.mapLive(7, 'late');
-- const db2 = new Database(snap.file);
-- db2.exec("INSERT INTO users VALUES (7, 'late', NULL, 'streamer'); INSERT INTO subscriptions VALUES (4, 2, 7, 1, 1, '2026-09-20 10:00:00', NULL, 'active', '2026-10-20 10:00:00', 'bucks');");
-- db2.close();
-- t.billing.pay(S[2], x);
-- t.billing.state.down = true;
-- let r;
-- try { r = await doImport(); } finally { t.billing.state.down = false; }
-- assert.strictEqual(r.ok, false);
-- assert.ok(r.billing_errors.length >= 1);
-- assert.strictEqual(await t.domain.memberships.get(S[2], (await t.domain.creators.bySubject(x)).id), null);
-- const row = await t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get();
-- assert.strictEqual(row.status, 'held');
-- assert.match(row.reason, /Billing was not reachable/);
-- const r2 = await doImport();
-- assert.strictEqual(r2.ok, true);
-- assert.strictEqual((await t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get()).status, 'linked');
-- assert.ok(await t.domain.memberships.get(S[2], (await t.domain.creators.bySubject(x)).id));
-- });
--
-- await run().finally(async () => { live.close(); fs.rmSync(snap.dir, { recursive: true, force: true }); return await t.close(); });
--})();