// 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()
})