openvibers/openvibe.community Commits

Importer: only rows between the OpenVibe launch and the id-space fix can hold a Network id (--ambiguous-from, default 2026-08-17T21:10:29Z); earlier rows were migrated from HoboStreamer with Live ids

OP OpenVibers committed 22 Sept 2026 · d7c6e608d8 · 3 files, +32 −11

Rehearsal against production data held 172 pastes as ambiguous under the whole-history window; 171 of them predate
the launch, when Live still used HoboStreamer's user ids, so they map by Live id.

Changes

ddiff --git a/scripts/import-pastes.js b/scripts/import-pastes.js
iindex e94b718..6f128f5 100644
---- a/scripts/import-pastes.js
++++ b/scripts/import-pastes.js
@@@ -4,7 +4,7 @@
/**
* Import OpenVibe.Media's paste export into Community's database.
*
-- * node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff 2026-08-20T03:00:26Z]
++ * node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff 2026-08-20T03:00:26Z] [--ambiguous-from 2026-08-17T21:10:29Z]
*
* Reads the same environment as the server (.env / /etc/openvibe/community.env):
* COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the
@@@ -17,17 +17,18 @@ const path = require('path');
function usage(msg) {
if (msg) console.error(msg);
-- console.error('usage: node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff <ISO time>]');
++ console.error('usage: node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff <ISO time>] [--ambiguous-from <ISO time>|none]');
process.exit(msg ? 1 : 0);
}
function parseArgs(argv) {
-- const out = { file: null, dryRun: false, cutoff: null };
++ const out = { file: null, dryRun: false, cutoff: null, ambiguousFrom: undefined };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--dry-run') out.dryRun = true;
else if (a === '--id-fix-cutoff') { out.cutoff = argv[++i]; if (!out.cutoff) usage('--id-fix-cutoff needs a value'); }
else if (a.startsWith('--id-fix-cutoff=')) out.cutoff = a.slice('--id-fix-cutoff='.length);
++ else if (a === '--ambiguous-from') { const v = argv[++i]; if (!v) usage('--ambiguous-from needs a value'); out.ambiguousFrom = v === 'none' ? null : v; }
else if (a === '-h' || a === '--help') usage();
else if (a.startsWith('-')) usage(`unknown option ${a}`);
else if (!out.file) out.file = a;
@@@ -55,6 +56,7 @@ async function main() {
// A dry run writes nothing at all, not even the display cache.
resolveLegacy: (system, list) => network.resolveLegacy(system, list, { cache: !args.dryRun }),
cutoff: args.cutoff || DEFAULT_CUTOFF,
++ ...(args.ambiguousFrom !== undefined ? { ambiguousFrom: args.ambiguousFrom } : {}),
dryRun: args.dryRun,
source: `${bundle.app || 'media'}:${path.basename(args.file)}`,
});
ddiff --git a/server/pastes/importer.js b/server/pastes/importer.js
iindex 8620192..023362a 100644
---- a/server/pastes/importer.js
++++ b/server/pastes/importer.js
@@@ -30,6 +30,8 @@ const store = require('./store');
const FORMAT = 'openvibe.media.pastes-export';
const DEFAULT_CUTOFF = '2026-08-20T03:00:26Z';
++// ac0d6a4 "Initial OpenVibe release" (2026-08-17 14:10:29 -07:00): earlier rows came from HoboStreamer's database.
++const DEFAULT_AMBIGUOUS_FROM = '2026-08-17T21:10:29Z';
// Media rebuilt screenshot metadata itself, so the ai_moment flag Live sent was dropped for image
// pastes; the frame file names Live's AI jobs upload are the remaining marker.
const AI_FRAME_NAME = /^(ai-moment-vod\d+-\d+|live-\d+-\d+)\.jpe?g$/i;
@@@ -77,10 +79,15 @@ function verifyBundle(bundle) {
* opts.source label for migration_runs
* → report
*/
--async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF, dryRun = false, source = 'media' } = {}) {
++async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF, ambiguousFrom = DEFAULT_AMBIGUOUS_FROM, dryRun = false, source = 'media' } = {}) {
verifyBundle(bundle);
const cutoffMs = toMs(cutoff);
if (!Number.isFinite(cutoffMs)) throw new ImportError(`bad cutoff ${cutoff}`);
++ const fromMs = ambiguousFrom ? toMs(ambiguousFrom) : -Infinity;
++ if (!Number.isFinite(fromMs) && fromMs !== -Infinity) throw new ImportError(`bad ambiguous-from ${ambiguousFrom}`);
++ // Only rows written between the OpenVibe launch and the id-space fix can hold a Network id: rows from
++ // before the launch were migrated from HoboStreamer's own database, whose user ids Live kept.
++ const inWindow = (createdAt) => { const t = toMs(createdAt); return t >= fromMs && !(t >= cutoffMs); };
const startedAt = new Date().toISOString();
// ── 1. Resolve every legacy user id before the transaction (network calls) ──
@@@ -89,7 +96,7 @@ async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF
const note = (userId, createdAt) => {
if (userId == null) return;
liveIds.add(String(userId));
-- if (!(toMs(createdAt) >= cutoffMs)) preCutoffIds.add(String(userId));
++ if (inWindow(createdAt)) preCutoffIds.add(String(userId));
};
for (const p of bundle.pastes) if (!isAiPaste(p)) note(p.user_id, p.created_at);
for (const c of bundle.comments) note(c.user_id, c.created_at);
@@@ -102,7 +109,7 @@ async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF
if (userId == null) return { subject: null, hold: null };
const key = String(userId);
const live = subjectOf(liveMap.get(key));
-- const pre = !(toMs(createdAt) >= cutoffMs);
++ const pre = inWindow(createdAt);
if (!pre) return live ? { subject: live, hold: null } : { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'post-cutoff' } };
const net = subjectOf(netMap.get(key));
if (live && net && live !== net) return { subject: null, hold: 'ambiguous_owner', detail: { user_id: userId, created_at: createdAt, live_subject: live, network_subject: net } };
@@@ -113,7 +120,7 @@ async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF
const blank = () => ({ bundle: 0, inserted: 0, updated: 0, unchanged: 0, held: 0, skipped: 0 });
const report = {
source, app: bundle.app || null, generated_at: bundle.generated_at || null, since_id: bundle.since_id ?? null, max_id: bundle.max_id ?? null,
-- cutoff, dry_run: !!dryRun,
++ cutoff, ambiguous_from: ambiguousFrom || null, dry_run: !!dryRun,
pastes: { ...blank(), bundle: bundle.pastes.length, ai: 0, forks_remapped: 0, forks_unresolved: 0 },
comments: { ...blank(), bundle: bundle.comments.length },
likes: { ...blank(), bundle: bundle.likes.length },
@@@ -304,16 +311,16 @@ function formatReport(r) {
const line = (name, t) => ` ${name.padEnd(9)} ${String(t.bundle).padStart(7)} ${String(t.inserted).padStart(9)} ${String(t.updated).padStart(8)} ${String(t.unchanged).padStart(10)} ${String(t.held).padStart(6)} ${String(t.skipped).padStart(8)}`;
const out = [
`Paste import from ${r.source}${r.dry_run ? ' (DRY RUN — rolled back)' : ''}`,
-- ` bundle app=${r.app} ids ${r.since_id ?? '?'}..${r.max_id ?? '?'} generated ${r.generated_at || '?'}; id-space cutoff ${r.cutoff}`,
++ ` bundle app=${r.app} ids ${r.since_id ?? '?'}..${r.max_id ?? '?'} generated ${r.generated_at || '?'}; ambiguous id window ${r.ambiguous_from || 'start'} .. ${r.cutoff}`,
'',
` ${'table'.padEnd(9)} ${'bundle'.padStart(7)} ${'inserted'.padStart(9)} ${'updated'.padStart(8)} ${'unchanged'.padStart(10)} ${'held'.padStart(6)} ${'skipped'.padStart(8)}`,
line('pastes', r.pastes), line('comments', r.comments), line('likes', r.likes),
'',
` AI pastes (ownerless): ${r.pastes.ai}; forks remapped: ${r.pastes.forks_remapped}, unresolved: ${r.pastes.forks_unresolved}`,
-- ` users: ${r.users.live_ids} live ids (${r.users.live_mapped} mapped), ${r.users.network_checked} pre-cutoff ids checked as network ids (${r.users.network_mapped} mapped)`,
++ ` users: ${r.users.live_ids} live ids (${r.users.live_mapped} mapped), ${r.users.network_checked} in-window ids checked as network ids (${r.users.network_mapped} mapped)`,
` holds: ${Object.keys(r.holds).length ? Object.entries(r.holds).map(([k, v]) => `${k}=${v}`).join(', ') : 'none'}`,
];
return out.join('\n');
}
--module.exports = { importBundle, verifyBundle, formatReport, isAiPaste, ImportError, FORMAT, DEFAULT_CUTOFF };
++module.exports = { DEFAULT_AMBIGUOUS_FROM, importBundle, verifyBundle, formatReport, isAiPaste, ImportError, FORMAT, DEFAULT_CUTOFF };
ddiff --git a/test/import.test.js b/test/import.test.js
iindex 66295b7..4dbff3c 100644
---- a/test/import.test.js
++++ b/test/import.test.js
@@@ -16,7 +16,8 @@ const { createNetworkIdentity } = require('../server/identity/network');
const mockNetwork = require('./helpers/mock-network');
const { check, done } = require('./helpers/app');
--const PRE = '2026-08-01 10:00:00'; // before the id-space fix (2026-08-20T03:00:26Z)
++const PRE = '2026-08-18 10:00:00'; // between the OpenVibe launch (2026-08-17T21:10:29Z) and the id-space fix (2026-08-20T03:00:26Z)
++const MIGRATED = '2026-08-01 10:00:00'; // before the launch: rows migrated from HoboStreamer, Live ids
const POST = '2026-09-01 10:00:00';
function seal(bundle) {
@@@ -225,6 +226,17 @@ const paste = (o) => ({
assert.strictEqual(r.pastes.updated, 2, 'the renamed paste and the newly owned one');
});
++ await check('rows from before the OpenVibe launch map by Live id even when the number is also a Network id', async () => {
++ const mdb = openDb(':memory:');
++ const r = await importBundle(mdb, seal({ pastes: [paste({ id: 70, slug: 'old-hobo-70', user_id: 9, created_at: MIGRATED, updated_at: MIGRATED })], comments: [], likes: [] }), { resolveLegacy: identity.resolveLegacy, source: 'test' });
++ const row = mdb.prepare('SELECT owner_subject FROM pastes WHERE slug = ?').get('old-hobo-70');
++ assert.strictEqual(row.owner_subject, C.subject_id, 'live 9 = cleo, not network 9 = sam');
++ assert.strictEqual(r.pastes.held, 0);
++ const r2 = await importBundle(openDb(':memory:'), seal({ pastes: [paste({ id: 70, slug: 'old-hobo-70', user_id: 9, created_at: MIGRATED, updated_at: MIGRATED })], comments: [], likes: [] }), { resolveLegacy: identity.resolveLegacy, source: 'test', ambiguousFrom: null });
++ assert.strictEqual(r2.pastes.held, 1, 'ambiguousFrom: null restores the whole-history window');
++ mdb.close();
++ });
++
await check('CLI: exit 2 on a refused bundle, dry run prints the report and writes nothing', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-import-'));
const dbPath = path.join(dir, 'c.db');