// FULL migration through the registry entry point (orgsToPerOrg via the
// runner): v1 single-orgs autobase + v1 separate per-org issue trackers
// (`issues:org:<name>`) -> v2 per-org HyperOrgs whose rooms carry the composed
// issues schema (#42 linked-core). Proves migrateOrgIssues (the orchestrator
// added to migrations.js) actually wires source trackers onto the org rooms —
// i.e. the migration is INVOKED end-to-end, not just the pure transform.
import test from 'brittle'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import { HyperOrgs } 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 { runMigrations } from '../lib/services/migrate/runner.js'
import { orgsToPerOrg } from '../lib/services/migrate/migrations.js'
// v1 single-`orgs` autobase: append create-org ops; apply puts org:{name}.
async function makeOldOrgsBase (t) {
const store = new Corestore(mkdtempSync(join(tmpdir(), 'lc-old-orgs-')))
const base = new Autobase(store, null, {
valueEncoding: 'json',
open (s) { return new Hyperbee(s.get('view', { valueEncoding: 'json' }), { keyEncoding: 'utf-8', valueEncoding: 'json' }) },
async apply (nodes, view) {
for (const node of nodes) {
const op = node.value
if (op && op.type === 'create-org') await view.put('org:' + op.org.name, op.org)
}
}
})
await base.ready()
t.teardown(async () => { try { await base.close() } catch {}; try { await store.close() } catch {} })
return base
}
// A v1 per-org issue tracker = a standalone HyperIssues (same separate-autobase
// shape prod used for `issues:org:<name>`).
async function makeOldOrgTracker (t, ns) {
const store = new Corestore(mkdtempSync(join(tmpdir(), 'lc-old-tracker-')))
const tracker = new HyperIssues(store, { namespace: ns })
await tracker.ready()
t.teardown(async () => { try { await tracker.close() } catch {}; try { await store.close() } catch {} })
return tracker
}
test('full orgsToPerOrg migration replays org issues into the linked-core org room', async (t) => {
// 1. v1 orgs
const oldOrgs = await makeOldOrgsBase(t)
const ORGS = [
{ name: 'acme', type: 'group', displayName: 'Acme', members: [{ userId: 'alice', role: 'owner' }], resources: [] },
{ name: 'globex', type: 'group', members: [], resources: [] }
]
for (const o of ORGS) await oldOrgs.append({ type: 'create-org', org: o })
await oldOrgs.update()
// 2. v1 separate issue trackers, one per org
const acmeTracker = await makeOldOrgTracker(t, 'issues:org:acme')
await acmeTracker.createLabel('bug', '#d73a4a', 'broken', 'Bug')
const a1 = await acmeTracker.create({ title: 'acme bug', body: 'b', author: 'alice', labels: ['bug'] })
const a2 = await acmeTracker.create({ title: 'acme feature', author: 'bob' })
await acmeTracker.update(a2, { status: 'closed' }, 'bob')
await acmeTracker.comment(a1, { body: 'repro', author: 'bob' })
const globexTracker = await makeOldOrgTracker(t, 'issues:org:globex')
const g1 = await globexTracker.create({ title: 'globex bug', author: 'carol' })
const oldTrackers = new Map([['acme', acmeTracker], ['globex', globexTracker]])
// 3. target: per-org HyperOrgs with the COMPOSED issues schema (#42 — the
// daemon injects extraSchemas:[issuesSchema] so each org room handles issue ops).
const newStore = new Corestore(mkdtempSync(join(tmpdir(), 'lc-new-')))
const newOrgs = new HyperOrgs(newStore, { namespace: 'orgs', extraSchemas: [issuesSchema] })
await newOrgs.ready()
t.teardown(async () => { try { await newOrgs.close() } catch {}; try { await newStore.close() } catch {} })
// 4. run the REAL migration via the runner, supplying the source opener.
let version = 0
const r = await runMigrations({
getVersion: async () => version,
setVersion: async (v) => { version = v },
migrations: [orgsToPerOrg],
ctx: {
oldOrgsBase: oldOrgs,
newOrgs,
openOldOrgTracker: async (name) => oldTrackers.get(name) || null
}
})
t.is(r.version, 2, 'schema v2 after migration')
// 5. orgs present
const orgs = await newOrgs.list()
t.alike(orgs.map(o => o.name).sort(), ['acme', 'globex'], 'both orgs migrated')
// 6. org issues live in the org's OWN room — wrap getOrgRoom with HyperIssues
const acmeRoom = newOrgs.getOrgRoom('acme')
t.ok(acmeRoom, 'acme has a room')
const acmeIssues = new HyperIssues(null, { room: acmeRoom, ownsRoom: false })
const i1 = await acmeIssues.get(a1)
const i2 = await acmeIssues.get(a2)
t.is(i1.title, 'acme bug', 'acme issue 1 migrated into the org room')
t.is(i1.author, 'alice', 'author preserved')
t.alike(i1.labels, ['bug'], 'labels preserved')
t.is(i2.status, 'closed', 'closed status preserved')
const c1 = await acmeIssues.getComments(a1)
t.is(c1.length, 1, 'comment migrated')
t.is(c1[0].body, 'repro', 'comment body preserved')
const globexRoom = newOrgs.getOrgRoom('globex')
const globexIssues = new HyperIssues(null, { room: globexRoom, ownsRoom: false })
const gi = await globexIssues.get(g1)
t.is(gi.title, 'globex bug', 'globex issue migrated into ITS own room')
// 7. LINKED-CORE property (#42): each org's issues live in ITS OWN autobase.
// (Both trackers number from 1, so isolation shows in counts + content, not
// ids: acme has 2 issues, globex 1, and globex's id-1 is "globex bug".)
t.not(acmeRoom.keyHex, globexRoom.keyHex, 'each org is its own autobase')
const acmeAll = await acmeIssues.list()
const globexAll = await globexIssues.list()
t.is(acmeAll.length, 2, 'acme room holds exactly its 2 issues')
t.is(globexAll.length, 1, 'globex room holds exactly its 1 issue')
t.absent(globexAll.find(i => i.title === 'acme bug'), "acme's issue did not bleed into globex's room")
t.absent(acmeAll.find(i => i.title === 'globex bug'), "globex's issue did not bleed into acme's room")
})