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.
Changes
ddeleted file mode 100644@@@ -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); });ddeleted file mode 100644@@@ -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 };ddeleted file mode 100644@@@ -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 };ddeleted file mode 100644@@@ -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(); });--})();
deleted file mode 100644
deleted file mode 100644
@@ -1,62 +0,0 @@
@@ -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); });
deleted file mode 100644
deleted file mode 100644
@@ -1,26 +0,0 @@
@@ -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 };
deleted file mode 100644
deleted file mode 100644
@@ -1,270 +0,0 @@
@@ -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 };
deleted file mode 100644
deleted file mode 100644
@@ -1,157 +0,0 @@
@@ -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(); });
-})();