import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { replicateAndSync } from 'autobase-test-helpers'
import { HyperIssues, ZERO_OID } from '../index.js'
function tmpStore () {
const dir = mkdtempSync(join(tmpdir(), 'hyper-issues-test-'))
return new Corestore(dir)
}
async function makeTracker (t, namespace = 'test') {
const store = tmpStore()
const tracker = new HyperIssues(store, { namespace })
await tracker.ready()
t.teardown(async () => {
try { await tracker.close() } catch {}
try { await store.close() } catch {}
})
return tracker
}
test('create and retrieve issue', async (t) => {
const tracker = await makeTracker(t, 'test-1')
const id = await tracker.create({
title: 'First bug',
body: 'Something is broken',
author: 'user-1'
})
t.is(id, 1)
const issue = await tracker.get(id)
t.is(issue.title, 'First bug')
t.is(issue.body, 'Something is broken')
t.is(issue.author, 'user-1')
t.is(issue.status, 'open')
})
test('update issue status', async (t) => {
const tracker = await makeTracker(t, 'test-2')
const id = await tracker.create({ title: 'Bug', author: 'user-1' })
await tracker.update(id, { status: 'closed' }, 'user-1')
const issue = await tracker.get(id)
t.is(issue.status, 'closed')
})
test('list issues with filters', async (t) => {
const tracker = await makeTracker(t, 'test-3')
await tracker.create({ title: 'Open bug', author: 'user-1' })
await tracker.create({ title: 'Another open', author: 'user-2' })
const closedId = await tracker.create({ title: 'Closed one', author: 'user-1' })
await tracker.update(closedId, { status: 'closed' }, 'user-1')
const open = await tracker.list({ status: 'open' })
t.is(open.length, 2)
const closed = await tracker.list({ status: 'closed' })
t.is(closed.length, 1)
const all = await tracker.list()
t.is(all.length, 3)
const byAuthor = await tracker.list({ author: 'user-1' })
t.is(byAuthor.length, 2)
const searched = await tracker.list({ search: 'another' })
t.is(searched.length, 1)
})
test('comments', async (t) => {
const tracker = await makeTracker(t, 'test-4')
const id = await tracker.create({ title: 'Bug', author: 'user-1' })
const commentId = await tracker.comment(id, {
body: 'I can reproduce this',
author: 'user-2'
})
t.ok(commentId)
const comments = await tracker.getComments(id)
t.is(comments.length, 1)
t.is(comments[0].body, 'I can reproduce this')
t.is(comments[0].author, 'user-2')
})
test('edit and delete comments', async (t) => {
const tracker = await makeTracker(t, 'test-4b')
const id = await tracker.create({ title: 'Bug', author: 'user-1' })
const cId = await tracker.comment(id, { body: 'first', author: 'user-2' })
await tracker.editComment(id, cId, 'edited', 'user-2')
let comments = await tracker.getComments(id)
t.is(comments[0].body, 'edited')
t.ok(comments[0].editedAt)
t.is(comments[0].editedBy, 'user-2')
await tracker.deleteComment(id, cId, 'user-2')
comments = await tracker.getComments(id)
t.is(comments.length, 0)
})
test('labels', async (t) => {
const tracker = await makeTracker(t, 'test-5')
await tracker.createLabel('bug', '#ff0000', 'Something broken', 'Bug')
await tracker.createLabel('feature', '#00ff00', 'New feature', 'Feature')
const labels = await tracker.listLabels()
t.is(labels.length, 2)
const bug = await tracker.getLabel('bug')
t.is(bug.color, '#ff0000')
t.is(bug.displayName, 'Bug')
await tracker.updateLabel('bug', { color: '#aa0000' })
const updated = await tracker.getLabel('bug')
t.is(updated.color, '#aa0000')
await tracker.deleteLabel('feature')
const after = await tracker.listLabels()
t.is(after.length, 1)
})
test('issue counts', async (t) => {
const tracker = await makeTracker(t, 'test-6')
await tracker.create({ title: 'one', author: 'user-1' })
await tracker.create({ title: 'two', author: 'user-1' })
const id3 = await tracker.create({ title: 'three', author: 'user-1' })
await tracker.update(id3, { status: 'closed' }, 'user-1')
const counts = await tracker.counts()
t.is(counts.open, 2)
t.is(counts.closed, 1)
t.is(counts.total, 3)
})
test('auto-incrementing IDs', async (t) => {
const tracker = await makeTracker(t, 'test-7')
const id1 = await tracker.create({ title: 'a', author: 'u' })
const id2 = await tracker.create({ title: 'b', author: 'u' })
const id3 = await tracker.create({ title: 'c', author: 'u' })
t.is(id1, 1)
t.is(id2, 2)
t.is(id3, 3)
})
test('refs (optional git feature)', async (t) => {
const tracker = await makeTracker(t, 'test-8')
await tracker.updateRef('refs/heads/main', 'abc123', 'user-1')
await tracker.updateRef('refs/heads/dev', 'def456', 'user-1')
const main = await tracker.getRef('refs/heads/main')
t.is(main.hash, 'abc123')
const refs = await tracker.listRefs()
t.is(refs.length, 2)
await tracker.deleteRef('refs/heads/dev', 'user-1')
const after = await tracker.listRefs()
t.is(after.length, 1)
})
// ── gip-overlay P1: fast-forward (compare-and-swap) refs ─────────
// The apply rejects a ref move whose declared prior oid (oldOid) does not
// match the current view value. Reachability-FF stays enforced locally by
// git-receive-pack; this guard is the cross-writer concurrency check.
test('refs FF: fast-forward update accepted', async (t) => {
const tracker = await makeTracker(t, 'test-ref-ff-1')
// create — no prior value (oldOid omitted == absent)
await tracker.updateRef('refs/heads/main', 'aaa', 'user-1')
t.is((await tracker.getRef('refs/heads/main')).hash, 'aaa')
// fast-forward: declare the observed oid, move it forward
await tracker.updateRef('refs/heads/main', 'bbb', 'user-1', 'aaa')
t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb', 'ref moved aaa -> bbb')
})
test('refs FF: non-fast-forward (stale oldOid) rejected', async (t) => {
const tracker = await makeTracker(t, 'test-ref-ff-2')
await tracker.updateRef('refs/heads/main', 'aaa', 'user-1') // create
await tracker.updateRef('refs/heads/main', 'bbb', 'user-1', 'aaa') // ff aaa -> bbb
t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb')
// stale: writer still believes ref is at 'aaa' — must be rejected
await tracker.updateRef('refs/heads/main', 'ccc', 'user-1', 'aaa')
t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb', 'stale update rejected, ref unchanged')
})
test('refs FF: create with non-zero oldOid rejected; zero-oid create accepted', async (t) => {
const tracker = await makeTracker(t, 'test-ref-ff-3')
// claims a prior value but ref is absent → reject
await tracker.updateRef('refs/heads/new', 'xxx', 'user-1', 'deadbeef')
t.is(await tracker.getRef('refs/heads/new'), null, 'phantom-prior create rejected')
// zero-oid explicitly means "must not exist" → accepted
await tracker.updateRef('refs/heads/new', 'xxx', 'user-1', ZERO_OID)
t.is((await tracker.getRef('refs/heads/new')).hash, 'xxx', 'zero-oid create accepted')
})
test('refs FF: linearized competing updates — first wins, second rejected', async (t) => {
const tracker = await makeTracker(t, 'test-ref-ff-4')
await tracker.updateRef('refs/heads/main', 'base', 'user-1') // create
// both updates branched from 'base'. After linearization the first
// applied wins; the second (still citing 'base') is non-FF and dropped.
await tracker.updateRef('refs/heads/main', 'win', 'user-1', 'base')
await tracker.updateRef('refs/heads/main', 'lose', 'user-2', 'base')
t.is((await tracker.getRef('refs/heads/main')).hash, 'win', 'first FF wins, second rejected')
})
test('refs FF: delete honours oldOid (stale delete rejected, matching accepted)', async (t) => {
const tracker = await makeTracker(t, 'test-ref-ff-5')
await tracker.updateRef('refs/heads/main', 'aaa', 'user-1')
await tracker.updateRef('refs/heads/main', 'bbb', 'user-1', 'aaa')
// stale delete (cites the old value) → rejected
await tracker.deleteRef('refs/heads/main', 'user-1', 'aaa')
t.is((await tracker.getRef('refs/heads/main')).hash, 'bbb', 'stale delete rejected')
// matching delete → accepted
await tracker.deleteRef('refs/heads/main', 'user-1', 'bbb')
t.is(await tracker.getRef('refs/heads/main'), null, 'matching delete removed ref')
})
// ── gip-overlay P3: bundle chain bookkeeping ────────────────────
// Each push records the overlay content hash of its delta bundle, keyed by
// the tip oid it produces, with the baseOid it builds on. A fetcher walks
// oid -> baseOid back to genesis (ZERO) to resolve which bundles to pull.
test('bundles: add and retrieve a bundle entry', async (t) => {
const tracker = await makeTracker(t, 'test-bundle-1')
await tracker.addBundle({ oid: 'o2', baseOid: 'o1', contentHash: 'hashB', size: 123, ref: 'refs/heads/main' }, 'user-1')
const b = await tracker.getBundle('o2')
t.is(b.baseOid, 'o1')
t.is(b.contentHash, 'hashB')
t.is(b.size, 123)
t.is(b.ref, 'refs/heads/main')
t.is(b.addedBy, 'user-1')
})
test('bundles: missing oid resolves to null; creation bundle defaults baseOid to ZERO', async (t) => {
const tracker = await makeTracker(t, 'test-bundle-2')
t.is(await tracker.getBundle('nope'), null, 'unknown oid → null')
await tracker.addBundle({ oid: 'genesis', contentHash: 'hashA', size: 9 }, 'user-1') // no baseOid
const b = await tracker.getBundle('genesis')
t.is(b.baseOid, ZERO_OID, 'creation bundle baseOid defaults to ZERO_OID')
})
test('bundles: add-bundle is idempotent (first writer wins)', async (t) => {
const tracker = await makeTracker(t, 'test-bundle-3')
await tracker.addBundle({ oid: 'o2', baseOid: 'o1', contentHash: 'first', size: 1 }, 'user-1')
await tracker.addBundle({ oid: 'o2', baseOid: 'o1', contentHash: 'second', size: 2 }, 'user-2')
const b = await tracker.getBundle('o2')
t.is(b.contentHash, 'first', 'replay/duplicate does not overwrite')
})
test('meta key-value storage', async (t) => {
const tracker = await makeTracker(t, 'test-9')
await tracker.setMeta('description', 'My project')
await tracker.setMeta('homepage', 'https://example.com')
t.is(await tracker.getMeta('description'), 'My project')
t.is(await tracker.getMeta('homepage'), 'https://example.com')
t.is(await tracker.getMeta('missing'), null)
})
test('key properties', async (t) => {
const tracker = await makeTracker(t, 'test-10')
t.ok(tracker.key, 'has key')
t.is(typeof tracker.keyHex, 'string')
t.is(tracker.keyHex.length, 64, 'keyHex is 32 bytes hex')
t.ok(tracker.localKey, 'has localKey')
t.is(tracker.writable, true, 'first writer is writable')
})
test('add labels to an issue', async (t) => {
const tracker = await makeTracker(t, 'test-11')
await tracker.createLabel('bug', '#ff0000')
await tracker.createLabel('urgent', '#ffaa00')
const id = await tracker.create({ title: 'Bug', author: 'user-1' })
await tracker.addLabels(id, ['bug', 'urgent'], 'user-1')
const issue = await tracker.get(id)
t.alike(issue.labels.sort(), ['bug', 'urgent'])
await tracker.removeLabels(id, ['urgent'], 'user-1')
const after = await tracker.get(id)
t.alike(after.labels, ['bug'])
})
test('initializeDefaultLabels', async (t) => {
const tracker = await makeTracker(t, 'test-12')
const labels = await tracker.initializeDefaultLabels()
t.ok(labels.length > 0, 'creates default labels')
t.ok(labels.find(l => l.name === 'bug'))
t.ok(labels.find(l => l.name === 'enhancement'))
// Calling again should not duplicate
await tracker.initializeDefaultLabels()
const second = await tracker.listLabels()
t.is(second.length, labels.length)
})
// ── Multi-writer replication ──────────────────────────────────
test('two trackers replicate via autobase', async (t) => {
const storeA = tmpStore()
const storeB = tmpStore()
const trackerA = new HyperIssues(storeA, { namespace: 'shared' })
await trackerA.ready()
// A creates an issue and a label
await trackerA.createLabel('bug', '#ff0000')
await trackerA.create({ title: 'shared bug', author: 'alice' })
await trackerA.base.update()
const trackerB = new HyperIssues(storeB, {
namespace: 'shared',
bootstrapKey: trackerA.keyHex
})
await trackerB.ready()
await replicateAndSync([trackerA.base, trackerB.base])
// B should see A's data
const issue = await trackerB.get(1)
t.ok(issue, 'B received the issue')
t.is(issue.title, 'shared bug')
const labels = await trackerB.listLabels()
t.is(labels.length, 1)
t.is(labels[0].name, 'bug')
t.teardown(async () => {
try { await trackerA.close() } catch {}
try { await trackerB.close() } catch {}
try { await storeA.close() } catch {}
try { await storeB.close() } catch {}
})
})
test('two writers can both append after addWriter', async (t) => {
const storeA = tmpStore()
const storeB = tmpStore()
const trackerA = new HyperIssues(storeA, { namespace: 'shared2' })
await trackerA.ready()
const trackerB = new HyperIssues(storeB, {
namespace: 'shared2',
bootstrapKey: trackerA.keyHex
})
await trackerB.ready()
// Initial sync so B knows the room structure
await replicateAndSync([trackerA.base, trackerB.base])
// A authorizes B as a writer
await trackerA.addWriter('bob', { writerKey: trackerB.localKeyHex, role: 'writer' })
await replicateAndSync([trackerA.base, trackerB.base])
t.ok(trackerB.writable, 'B is now writable')
// Alice creates first, sync, then Bob creates.
// (Concurrent create() from two writers picks the same auto-increment ID
// because each writer reads its local counter independently. This is a
// known limitation of auto-increment in multiwriter — to avoid the
// collision, sync between creates so each peer sees the latest counter.
// For collision-free concurrent IDs, use UUID-style IDs in the schema.)
await trackerA.create({ title: 'from alice', author: 'alice' })
await replicateAndSync([trackerA.base, trackerB.base])
await trackerB.create({ title: 'from bob', author: 'bob' })
await replicateAndSync([trackerA.base, trackerB.base])
// Both should see both issues
const allA = await trackerA.list({ sort: 'oldest' })
const allB = await trackerB.list({ sort: 'oldest' })
t.is(allA.length, 2, 'A sees both')
t.is(allB.length, 2, 'B sees both')
t.alike(allA.map(i => i.title).sort(), ['from alice', 'from bob'])
t.teardown(async () => {
try { await trackerA.close() } catch {}
try { await trackerB.close() } catch {}
try { await storeA.close() } catch {}
try { await storeB.close() } catch {}
})
})
// ── gip-overlay P4: writer-set membership IS the ref push authority ──
// A node may advance refs iff its writer key is in the per-repo autobase's
// writer set. Grant = addWriter (repo invite/join); revoke = removeWriter.
// A non-writer's op never reaches the linearized view (forgery rejected);
// reads are open regardless.
test('refs authority: writer-set gates ref advances (forgery / grant / revoke)', async (t) => {
const storeA = tmpStore()
const storeB = tmpStore()
const A = new HyperIssues(storeA, { namespace: 'gip-auth' })
await A.ready()
const B = new HyperIssues(storeB, { namespace: 'gip-auth', bootstrapKey: A.keyHex })
await B.ready()
t.teardown(async () => {
for (const x of [A, B, storeA, storeB]) { try { await x.close() } catch {} }
})
await replicateAndSync([A.base, B.base])
// 1. forgery: B is NOT a writer — its ref op must not reach A's view
t.absent(B.writable, 'B is not a writer yet')
try { await B.updateRef('refs/heads/main', 'aaa', 'bob') } catch {}
await replicateAndSync([A.base, B.base])
t.is(await A.getRef('refs/heads/main'), null, 'non-writer ref op rejected (forgery)')
// 2. grant: addWriter B → B advances the ref, A sees it
await A.addWriter('bob', { writerKey: B.localKeyHex, role: 'writer' })
await replicateAndSync([A.base, B.base])
t.ok(B.writable, 'B writable after grant')
await B.updateRef('refs/heads/main', 'bbb', 'bob')
await replicateAndSync([A.base, B.base])
t.is((await A.getRef('refs/heads/main')).hash, 'bbb', 'granted writer advance accepted')
// 3. revoke: removeWriter B → B's later op is not applied; reads still work
await A.removeWriter('bob')
await replicateAndSync([A.base, B.base])
try { await B.updateRef('refs/heads/main', 'ccc', 'bob', 'bbb') } catch {}
await replicateAndSync([A.base, B.base])
t.is((await A.getRef('refs/heads/main')).hash, 'bbb', 'post-revoke op not applied — ref unchanged')
t.is((await B.getRef('refs/heads/main')).hash, 'bbb', 'revoked node can still READ')
// 4. remaining writer (A) keeps write authority
await A.updateRef('refs/heads/main', 'ddd', 'alice', 'bbb')
await replicateAndSync([A.base, B.base])
t.is((await A.getRef('refs/heads/main')).hash, 'ddd', 'owner still advances refs after revoke')
})
test('createWithId preserves id, status, and timestamps (migration path)', async (t) => {
const tracker = await makeTracker(t, 'migration-1')
await tracker.createWithId(0, {
title: 'issue zero',
body: 'v1 used id=0',
author: 'fleeky',
status: 'closed',
createdAt: 1733078495188,
updatedAt: 1734033780311
})
await tracker.createWithId(42, {
title: 'sparse id',
author: 'fleeky',
status: 'open',
createdAt: 1700000000000
})
const zero = await tracker.get(0)
t.is(zero.id, 0, 'id=0 preserved')
t.is(zero.status, 'closed')
t.is(zero.createdAt, 1733078495188, 'createdAt preserved')
t.is(zero.updatedAt, 1734033780311, 'updatedAt preserved')
const forty2 = await tracker.get(42)
t.is(forty2.id, 42)
t.is(forty2.createdAt, 1700000000000)
const newId = await tracker.create({ title: 'post-migration', author: 'fleeky' })
t.is(newId, 43, 'auto-increment picks up after highest migrated id')
})
test('createWithId is idempotent (second call skips overwrite)', async (t) => {
const tracker = await makeTracker(t, 'migration-2')
await tracker.createWithId(5, { title: 'first', author: 'fleeky', status: 'open' })
await tracker.createWithId(5, { title: 'OVERWRITE ATTEMPT', author: 'malicious', status: 'closed' })
const issue = await tracker.get(5)
t.is(issue.title, 'first', 'original preserved')
t.is(issue.author, 'fleeky')
t.is(issue.status, 'open')
})
test('commentWithId preserves commentId + createdAt (migration path)', async (t) => {
const tracker = await makeTracker(t, 'migration-3')
await tracker.createWithId(1, { title: 'with comments', author: 'fleeky' })
await tracker.commentWithId(1, 'v1-comment-id-abc', {
body: 'first comment',
author: 'fleeky',
createdAt: 1700000000000
})
await tracker.commentWithId(1, 'v1-comment-id-abc', {
body: 'OVERWRITE',
author: 'mal'
})
const comments = await tracker.getComments(1)
t.is(comments.length, 1)
t.is(comments[0].commentId, 'v1-comment-id-abc', 'commentId preserved')
t.is(comments[0].body, 'first comment', 'original body preserved')
t.is(comments[0].createdAt, 1700000000000, 'createdAt preserved')
})