OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / scripts / migrate-to-unified.js

332 lines · javascript ref:
#!/usr/bin/env bare

// Load Node.js compatibility for Bare runtime
await import('bare-node-runtime/global')

/**
 * migrate-to-unified.js — Export data from old Autobases, import into unified MetadataBase
 *
 * Uses the REAL old apply functions (scripts/old-apply/) to open old Autobases
 * and read their materialized views. Writes v2 operations to a fresh MetadataBase.
 *
 * Usage:
 *   node scripts/migrate-to-unified.js [--storage /path/to/storage] [--force]
 *
 * Prerequisites:
 *   - config:metadataBase must be deleted from registry (forces fresh MetadataBase)
 *   - scripts/old-apply/ must contain the old apply + operations files
 */

import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import Autobase from 'autobase'
import path from 'bare-path'
import os from 'bare-os'
import { MetadataBase } from '../lib/metadata-base.js'

// Old apply functions (from git history, with real key schemas)
import { apply as identityApply } from './old-apply/identity-apply.js'
import { apply as namespaceApply } from './old-apply/namespace-apply.js'
import { apply as deployApply } from './old-apply/deploy-apply.js'
import { apply as repoApply } from './old-apply/repo-apply.js'

// Suppress crashes from corrupted adopted LinkedRepos
let _suppress = false
process.on('uncaughtException', (err) => {
  if (_suppress) return
  console.error('Uncaught:', err.message)
  process.exit(1)
})

// Parse args
const args = process.argv.slice(2)
let storagePath = null
const forceRerun = args.includes('--force')
for (let i = 0; i < args.length; i++) {
  if (args[i] === '--storage' && args[i + 1]) storagePath = args[i + 1]
}
if (!storagePath) {
  storagePath = path.join(os.homedir(), '.config', 'pear-git', 'server-data')
}

console.log(`[Migration] Storage: ${storagePath}`)
const store = new Corestore(storagePath)
const registryCore = store.get({ name: 'registry' })
const registry = new Hyperbee(registryCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
await registry.ready()

// Check idempotency
const migrationDone = await registry.get('config:unifiedMigration')
if (migrationDone && !forceRerun) {
  console.log(`[Migration] Already completed at ${migrationDone.value.completedAt}`)
  console.log('[Migration] Pass --force to re-run')
  await store.close()
  process.exit(0)
}

// Ensure MetadataBase config is fresh (or doesn't exist yet)
const mbConfig = await registry.get('config:metadataBase')
if (mbConfig) {
  console.log('[Migration] MetadataBase config exists — will use existing')
} else {
  console.log('[Migration] No MetadataBase config — will create fresh')
}

// Open MetadataBase
const metadataBase = new MetadataBase({
  storagePath: storagePath,
  bootstrapKey: mbConfig?.value?.bootstrapKey || null
})
await metadataBase.ready()

// Persist new key if created fresh
if (!mbConfig) {
  await registry.put('config:metadataBase', {
    bootstrapKey: metadataBase.bootstrapKeyHex,
    createdAt: Date.now()
  })
  console.log(`[Migration] Created fresh MetadataBase: ${metadataBase.bootstrapKeyHex.slice(0, 16)}`)
} else {
  console.log(`[Migration] MetadataBase: ${metadataBase.bootstrapKeyHex.slice(0, 16)}`)
}

const stats = { users: 0, identities: 0, servers: 0, deploys: 0, groups: 0, peers: 0, grants: 0, invites: 0, requests: 0, issues: 0, comments: 0, labels: 0, repoWriters: 0, repos: 0, errors: 0, skipped: 0 }

// ── Helper: open old Autobase with REAL apply function ────────

async function openOldBase (namespace, bootstrapKey, applyFn, timeoutMs = 30000) {
  if (!bootstrapKey) return null
  const cs = store.namespace(namespace)
  const keyBuf = typeof bootstrapKey === 'string' ? Buffer.from(bootstrapKey, 'hex') : bootstrapKey

  const base = new Autobase(cs, keyBuf, {
    open: (s) => new Hyperbee(s.get('view', { valueEncoding: 'json' }), {
      keyEncoding: 'utf-8',
      valueEncoding: 'json'
    }),
    apply: applyFn,
    valueEncoding: 'binary'
  })

  await Promise.race([
    base.ready(),
    new Promise((_, rej) => setTimeout(() => rej(new Error('ready timeout')), timeoutMs))
  ])

  await Promise.race([
    base.update(),
    new Promise((_, rej) => setTimeout(() => rej(new Error('update timeout')), timeoutMs))
  ])

  return base
}

async function readAll (view, prefix) {
  const results = []
  for await (const e of view.createReadStream({ gte: prefix, lt: prefix + '\xff' })) {
    results.push({ key: e.key, value: e.value })
  }
  return results
}

// ── 1. Identity Registry ──────────────────────────────────────

console.log('\n[Migration] Phase 1: Identity Registry')
const idKey = (await registry.get('config:identityRegistry'))?.value?.bootstrapKey
if (idKey) {
  try {
    const base = await openOldBase('identity-registry', idKey, identityApply)

    for (const { value } of await readAll(base.view, 'user:')) {
      try {
        await metadataBase.append({ v: 2, ns: 'identity', type: 'create-user', userId: value.userId, username: value.username || null, name: value.name || 'Anonymous', email: value.email || null, profilePicture: value.profilePicture || null, globalPermissions: value.globalPermissions || ['read'], repos: value.repos || [{ name: '*', permissions: ['read'] }], timestamp: value.createdAt || Date.now() })
        if (value.deactivatedAt) {
          await metadataBase.append({ v: 2, ns: 'identity', type: 'deactivate-user', userId: value.userId, deactivatedBy: value.deactivatedBy || 'system', reason: value.deactivationReason || null, timestamp: value.deactivatedAt })
        }
        stats.users++
      } catch (err) { stats.errors++; console.error(`  user ${value.userId}: ${err.message}`) }
    }
    console.log(`  Users: ${stats.users}`)

    for (const { value } of await readAll(base.view, 'identity:')) {
      try {
        await metadataBase.append({ v: 2, ns: 'identity', type: 'create-identity', identityId: value.identityId, method: value.method, userId: value.userId || null, credentials: value.credentials || {}, timestamp: value.createdAt || Date.now() })
        if (value.revoked) {
          await metadataBase.append({ v: 2, ns: 'identity', type: 'revoke-identity', identityId: value.identityId, revokedBy: value.revokedBy || 'system', reason: value.revocationReason || 'migrated', timestamp: value.revokedAt || Date.now() })
        }
        stats.identities++
      } catch (err) { stats.errors++; console.error(`  identity ${value.identityId}: ${err.message}`) }
    }
    console.log(`  Identities: ${stats.identities}`)
    await base.close()
  } catch (err) {
    console.error(`  Failed to open Identity Registry: ${err.message}`)
    stats.errors++
  }
} else { console.log('  No identity registry — skipping') }

// ── 2. Deploy Registry ────────────────────────────────────────

console.log('\n[Migration] Phase 2: Deploy Registry')
const deployKey = (await registry.get('config:deployRegistry'))?.value?.bootstrapKey
if (deployKey) {
  try {
    const base = await openOldBase('deploy-registry', deployKey, deployApply)

    for (const { value } of await readAll(base.view, 'server:')) {
      try {
        await metadataBase.append({ v: 2, ns: 'deploy', type: 'register-server', serverKey: value.serverKey, instanceUrl: value.instanceUrl || null, hostname: value.hostname || null, timestamp: value.registeredAt || Date.now() })
        stats.servers++
      } catch (err) { stats.errors++ }
    }

    for (const { value } of await readAll(base.view, 'app:')) {
      try {
        await metadataBase.append({ v: 2, ns: 'deploy', type: 'deploy-complete', repoName: value.repoName, commitHash: value.commitHash, branch: value.branch, channel: value.channel || value.repoName, serverKey: value.deployedBy || null, timestamp: value.deployedAt || Date.now() })
        stats.deploys++
      } catch (err) { stats.errors++ }
    }
    console.log(`  Servers: ${stats.servers}, Deploys: ${stats.deploys}`)
    await base.close()
  } catch (err) {
    console.error(`  Failed to open Deploy Registry: ${err.message}`)
    stats.errors++
  }
} else { console.log('  No deploy registry — skipping') }

// ── 3. Namespace Registry ─────────────────────────────────────

console.log('\n[Migration] Phase 3: Namespace Registry')
const nsKey = (await registry.get('config:namespaceRegistry'))?.value?.bootstrapKey
if (nsKey) {
  try {
    const base = await openOldBase('namespace-registry', nsKey, namespaceApply)

    for (const { value } of await readAll(base.view, 'namespace:')) {
      try {
        await metadataBase.append({ v: 2, ns: 'group', type: 'create-namespace', name: value.name, nsType: value.type, displayName: value.displayName || value.name, description: value.description || '', userId: value.userId || null, members: value.members || [], visibility: value.visibility || 'public', timestamp: value.createdAt || Date.now() })
        for (const repoName of (value.repos || [])) {
          await metadataBase.append({ v: 2, ns: 'group', type: 'add-repo', namespaceName: value.name, repoName, timestamp: value.updatedAt || Date.now() })
        }
        stats.groups++
      } catch (err) { stats.errors++; console.error(`  group ${value.name}: ${err.message}`) }
    }

    for (const { key, value } of await readAll(base.view, 'writer:')) {
      if (value.removedAt) continue
      const peerId = key.slice('writer:'.length)
      try {
        await metadataBase.append({ v: 2, ns: 'group', type: 'add-writer', peerId, serverKey: value.serverKey, role: value.role || 'writer', addedBy: value.addedBy || null, timestamp: value.addedAt || Date.now() })
        stats.peers++
      } catch (err) { stats.errors++ }
    }

    for (const { value } of await readAll(base.view, 'grant:')) {
      try {
        await metadataBase.append({ v: 2, ns: 'group', type: 'create-grant', grantId: value.grantId, subjectType: value.subjectType, subjectId: value.subjectId, grantType: value.grantType, groupName: value.groupName || null, repoName: value.repoName || null, permission: value.permission, grantedBy: value.grantedBy || null, timestamp: value.grantedAt || Date.now() })
        stats.grants++
      } catch (err) { stats.errors++ }
    }

    for (const { value } of await readAll(base.view, 'invite:')) {
      try {
        await metadataBase.append({ v: 2, ns: 'group', type: 'create-invite', inviteId: value.inviteId, inviteType: value.inviteType, targetGroup: value.targetGroup || null, targetRepo: value.targetRepo || null, permission: value.permission, maxUses: value.maxUses || 1, createdBy: value.createdBy || null, expiresAt: value.expiresAt || null, timestamp: value.createdAt || Date.now() })
        stats.invites++
      } catch (err) { stats.errors++ }
    }

    console.log(`  Groups: ${stats.groups}, Peers: ${stats.peers}, Grants: ${stats.grants}, Invites: ${stats.invites}`)
    await base.close()
  } catch (err) {
    console.error(`  Failed to open Namespace Registry: ${err.message}`)
    stats.errors++
  }
} else { console.log('  No namespace registry — skipping') }

// ── 4. LinkedRepos (per-repo issues) ──────────────────────────

console.log('\n[Migration] Phase 4: LinkedRepos')

const repoConfigs = []
for await (const e of registry.createReadStream({ gt: 'config:issueAutobase:', lt: 'config:issueAutobase:\xff' })) {
  const repoName = e.key.slice('config:issueAutobase:'.length)
  // Skip adopted/federated repos — their Autobases have corrupted signers
  if (e.value.adoptedFrom) {
    console.log(`  Skipping ${repoName}: adopted from ${e.value.adoptedFrom.slice(0, 8)} (corrupted signers)`)
    stats.skipped++
    continue
  }
  repoConfigs.push({ repoName, bootstrapKey: e.value.bootstrapKey })
}
console.log(`  Found ${repoConfigs.length} repo configs`)

for (const { repoName, bootstrapKey } of repoConfigs) {
  let base
  _suppress = true
  try {
    base = await openOldBase(`linked:${repoName}`, bootstrapKey, repoApply, 15000)
  } catch (err) {
    console.log(`  Skipping ${repoName}: ${err.message}`)
    stats.skipped++
    _suppress = false
    continue
  }
  _suppress = false
  if (!base) { stats.skipped++; continue }

  let ri = 0, rc = 0, rl = 0, rw = 0

  for (const { key, value } of await readAll(base.view, 'writer:')) {
    const userId = key.slice('writer:'.length)
    try {
      await metadataBase.append({ v: 2, ns: 'issue', type: 'add-writer', repoName, userId, serverKey: value.serverKey, identityKey: value.identityKey || null, proof: value.proof || null, role: value.role || 'writer', addedBy: value.addedBy || null, timestamp: value.addedAt || Date.now() })
      rw++; stats.repoWriters++
    } catch (err) { stats.errors++ }
  }

  for (const { key, value } of await readAll(base.view, 'label:')) {
    const name = key.slice('label:'.length)
    try {
      await metadataBase.append({ v: 2, ns: 'issue', type: 'create-label', repoName, name, color: value.color || '#ccc', description: value.description || '', displayName: value.displayName || name })
      rl++; stats.labels++
    } catch (err) { stats.errors++ }
  }

  for (const { value } of await readAll(base.view, 'issue:')) {
    try {
      await metadataBase.append({ v: 2, ns: 'issue', type: 'create-issue', repoName, id: value.id, title: value.title, body: value.body || '', userId: value.author, status: value.status || 'open', labels: value.labels || [], images: value.images || [], timestamp: value.createdAt || Date.now() })
      ri++; stats.issues++
    } catch (err) { stats.errors++; console.error(`  issue ${repoName}#${value.id}: ${err.message}`) }
  }

  for (const { key, value } of await readAll(base.view, 'comment:')) {
    const parts = key.slice('comment:'.length).split(':')
    const issueId = parseInt(parts[0], 10)
    const commentId = parts.slice(1).join(':')
    try {
      await metadataBase.append({ v: 2, ns: 'issue', type: 'create-comment', repoName, issueId, commentId, body: value.body, userId: value.author, timestamp: value.createdAt || Date.now() })
      rc++; stats.comments++
    } catch (err) { stats.errors++ }
  }

  try { await base.close() } catch {}
  if (ri + rc + rl + rw > 0) console.log(`  ${repoName}: ${ri} issues, ${rc} comments, ${rl} labels, ${rw} writers`)
  stats.repos++
}

// ── Write migration flag ──────────────────────────────────────

await registry.put('config:unifiedMigration', { completedAt: new Date().toISOString(), stats })

console.log('\n═══════════════════════════════════════════')
console.log(`  Users: ${stats.users}  Identities: ${stats.identities}`)
console.log(`  Servers: ${stats.servers}  Deploys: ${stats.deploys}`)
console.log(`  Groups: ${stats.groups}  Peers: ${stats.peers}  Grants: ${stats.grants}  Invites: ${stats.invites}`)
console.log(`  Repos: ${stats.repos}  Issues: ${stats.issues}  Comments: ${stats.comments}  Labels: ${stats.labels}`)
console.log(`  Repo writers: ${stats.repoWriters}  Skipped: ${stats.skipped}  Errors: ${stats.errors}`)
console.log('═══════════════════════════════════════════')

await metadataBase.close()
await store.close()
console.log(stats.errors > 0 ? '\n⚠️  Completed with errors' : '\n✓ Migration complete')