OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / migrate-boot.test.js

161 lines · javascript ref:
// migrateStore (#52 Phase 1.1 + 1.2): the in-place, version-gated boot
// migration against the REAL reopen mechanism. Seed v1 data (single-orgs
// autobase + separate per-org issue trackers) on a corestore, close it,
// REOPEN the same on-disk corestore (simulating a daemon reboot with old
// data), construct the v2 per-org HyperOrgs, and run migrateStore — exactly
// what daemon boot will do. Asserts: orgs+issues migrate, version gates,
// re-run is a no-op, and the v1 cores are NOT destroyed (coexistence).

import test from 'brittle'
import Corestore from 'corestore'
import { Room } from 'hyper-rooms'
import { HyperOrgs, orgsSchema } from 'hyper-orgs'
import { HyperIssues, issuesSchema } from 'hyper-issues'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'

import { migrateStore } from '../lib/services/migrate/boot.js'
import { openOldOrgsBase } from '../lib/services/migrate/open-old-sources.js'

// Faithful v1 create-org op (what 0.2.1 HyperOrgs.create appended).
function createOrgOp (name, { members = [], displayName, type = 'group' } = {}) {
  return {
    type: 'create-org',
    name,
    orgType: type,
    displayName: displayName || name,
    description: '',
    about: '',
    userId: null,
    members,
    visibility: 'public',
    timestamp: Date.now()
  }
}

test('migrateStore: in-place v1 -> v2 on a reopened corestore, version-gated', async (t) => {
  const dir = mkdtempSync(join(tmpdir(), 'mig-boot-'))

  // ── Phase A: seed v1 data IN-PLACE, then close everything ──────────────
  {
    const store = new Corestore(dir)
    // v1 single-orgs autobase: a Room at namespace 'orgs' with create-org ops.
    const orgsRoom = new Room(store, { schema: orgsSchema, namespace: 'orgs' })
    await orgsRoom.ready()
    // Real v1 member roles are admin|member only (the owner is the creator /
    // writer, not a members entry — verified against prod's org data).
    await orgsRoom.append(createOrgOp('acme', { members: [{ userId: 'alice', role: 'admin' }] }))
    await orgsRoom.append(createOrgOp('globex'))
    await orgsRoom.base.update()

    // v1 separate per-org issue trackers.
    const acme = new HyperIssues(store, { namespace: 'issues:org:acme' })
    await acme.ready()
    await acme.createLabel('bug', '#d73a4a', 'broken', 'Bug')
    const a1 = await acme.create({ title: 'acme issue', body: 'b', author: 'alice', labels: ['bug'] })
    await acme.comment(a1, { body: 'a comment', author: 'bob' })

    const globex = new HyperIssues(store, { namespace: 'issues:org:globex' })
    await globex.ready()
    await globex.create({ title: 'globex issue', author: 'carol' })

    await acme.close(); await globex.close(); await orgsRoom.close()
    await store.close()
  }

  // ── Phase B: REOPEN the same on-disk store, run the boot migration ─────
  const store = new Corestore(dir)
  const newOrgs = new HyperOrgs(store, { namespace: 'orgs', extraSchemas: [issuesSchema] })
  await newOrgs.ready()
  t.is((await newOrgs.list()).length, 0, 'v2 HyperOrgs starts empty on the old store (no collision read)')

  let version = 0
  const versionStore = {
    getVersion: async () => version,
    setVersion: async (v) => { version = v }
  }

  const res = await migrateStore({ store: undefined, corestore: store, newOrgs, ...versionStore })
  t.is(res.skipped, false, 'migration ran (store was at v0)')
  t.is(res.version, 2, 'store bumped to v2')
  t.alike(res.ran, [2], 'ran exactly the v2 migration')
  t.is(version, 2, 'version persisted via setVersion')

  // ── orgs migrated into the per-org model ──────────────────────────────
  const orgs = await newOrgs.list()
  t.alike(orgs.map(o => o.name).sort(), ['acme', 'globex'], 'both orgs migrated in-place')
  const acmeOrg = await newOrgs.get('acme')
  t.ok((acmeOrg.members || []).some(m => m.userId === 'alice' && m.role === 'admin'), 'acme admin member preserved with role')

  // ── org issues migrated into each org's OWN room (#42 linked-core) ─────
  const acmeIssues = new HyperIssues(null, { room: newOrgs.getOrgRoom('acme'), ownsRoom: false })
  const ai = await acmeIssues.list()
  t.is(ai.length, 1, 'acme issue migrated into its org room')
  t.is(ai[0].title, 'acme issue', 'acme issue title preserved')
  t.is(ai[0].labels[0], 'bug', 'acme issue label preserved')
  const ac = await acmeIssues.getComments(ai[0].id)
  t.is(ac.length, 1, 'acme issue comment migrated')

  const globexIssues = new HyperIssues(null, { room: newOrgs.getOrgRoom('globex'), ownsRoom: false })
  const gi = await globexIssues.list()
  t.is(gi.length, 1, 'globex issue migrated into ITS own room')
  t.is(gi[0].title, 'globex issue')
  t.absent(gi.find(i => i.title === 'acme issue'), 'no cross-org bleed')

  // ── idempotent: a second boot is a no-op (version already at target) ───
  const res2 = await migrateStore({ corestore: store, newOrgs, ...versionStore })
  t.is(res2.skipped, true, 'second boot skips (already v2)')
  t.alike(res2.ran || [], [], 'nothing re-ran')

  // ── coexistence: the v1 orgs autobase is intact, NOT destroyed ─────────
  const oldBase = await openOldOrgsBase(store, { namespace: 'orgs' })
  const oldNames = []
  for await (const e of oldBase.view.createReadStream({ gt: 'org:', lt: 'org:\xff' })) oldNames.push(e.value.name)
  t.alike(oldNames.sort(), ['acme', 'globex'], 'v1 orgs data still readable after migration (rollback-safe)')
  await oldBase.close()

  await newOrgs.close()
  await store.close()
})

test('migrateStore: a MIRROR never self-migrates (#52 4.4 origin-gate)', async (t) => {
  const dir = mkdtempSync(join(tmpdir(), 'mig-mirror-'))

  // Seed v1 data so there WOULD be something to migrate if the gate failed.
  {
    const store = new Corestore(dir)
    const orgsRoom = new Room(store, { schema: orgsSchema, namespace: 'orgs' })
    await orgsRoom.ready()
    await orgsRoom.append(createOrgOp('acme'))
    await orgsRoom.base.update()
    await orgsRoom.close()
    await store.close()
  }

  const store = new Corestore(dir)
  const newOrgs = new HyperOrgs(store, { namespace: 'orgs', extraSchemas: [issuesSchema] })
  await newOrgs.ready()

  let version = 0
  let setCalled = false
  const res = await migrateStore({
    corestore: store,
    newOrgs,
    getVersion: async () => version,
    setVersion: async (v) => { setCalled = true; version = v },
    isMirror: true // booted from a deploymentBundle
  })

  t.is(res.skipped, true, 'mirror skipped migration')
  t.is(res.reason, 'mirror', 'reason recorded')
  t.is(version, 0, 'version NOT bumped (mirror stays at the origin-driven version)')
  t.absent(setCalled, 'setVersion never called on a mirror')
  // The mirror must NOT have minted its own per-org orgs — those arrive via
  // OrgPuller.mountOrg from the origin's announce, not self-migration.
  t.is((await newOrgs.list()).length, 0, 'mirror minted NO divergent per-org autobases')

  await newOrgs.close()
  await store.close()
})