/**
* issuesSchema — hyper-rooms schema for issue tracking
*
* Op types (writer ops are handled by Room):
* create-issue, update-issue
* create-comment, update-comment, delete-comment
* create-label, update-label, delete-label
* update-ref, delete-ref (optional, for git integration; FF-enforced)
* add-bundle (optional, gip-overlay bundle chain)
* set-meta
*
* View schema (Hyperbee keys):
* issue:{id} → { id, title, body, author, status, labels, images, createdAt, updatedAt }
* comment:{issueId}:{id} → { body, author, createdAt, editedAt?, editedBy? }
* label:{name} → { color, description, displayName }
* ref:{refPath} → { hash, updatedBy, timestamp }
* bundle:{oid} → { baseOid, contentHash, size, ref, addedBy, timestamp }
* meta:{key} → { value }
* counter:issues → { next: number }
*/
import { jsonSchema } from 'hyper-rooms'
// git's all-zero oid — the "ref does not exist" sentinel that
// git-receive-pack passes as <oldrev> on ref creation.
export const ZERO_OID = '0000000000000000000000000000000000000000'
// Normalize a declared previous-oid to either a concrete oid or null
// (meaning "the ref must be absent"). Absent ⇔ omitted / falsy / zero-oid.
function normalizeOldOid (oldOid) {
if (!oldOid || oldOid === ZERO_OID) return null
return oldOid
}
export const issuesSchema = jsonSchema({
validate (op) {
switch (op.type) {
case 'create-issue':
if (typeof op.id !== 'number') throw new Error('create-issue requires numeric id')
if (!op.title) throw new Error('create-issue requires title')
if (!op.userId) throw new Error('create-issue requires userId')
break
case 'update-issue':
if (typeof op.id !== 'number') throw new Error('update-issue requires numeric id')
if (!op.userId) throw new Error('update-issue requires userId')
break
case 'create-comment':
if (typeof op.issueId !== 'number') throw new Error('create-comment requires numeric issueId')
if (!op.commentId) throw new Error('create-comment requires commentId')
if (!op.body) throw new Error('create-comment requires body')
if (!op.userId) throw new Error('create-comment requires userId')
break
case 'update-comment':
case 'delete-comment':
if (typeof op.issueId !== 'number') throw new Error(op.type + ' requires numeric issueId')
if (!op.commentId) throw new Error(op.type + ' requires commentId')
break
case 'create-label':
case 'update-label':
case 'delete-label':
if (!op.name) throw new Error(op.type + ' requires name')
break
case 'update-ref':
if (!op.ref) throw new Error('update-ref requires ref')
if (!op.hash) throw new Error('update-ref requires hash')
if (op.oldOid != null && typeof op.oldOid !== 'string') throw new Error('update-ref oldOid must be a string')
break
case 'delete-ref':
if (!op.ref) throw new Error('delete-ref requires ref')
if (op.oldOid != null && typeof op.oldOid !== 'string') throw new Error('delete-ref oldOid must be a string')
break
case 'add-bundle':
if (!op.oid) throw new Error('add-bundle requires oid')
if (!op.contentHash) throw new Error('add-bundle requires contentHash')
break
case 'set-meta':
if (!op.key) throw new Error('set-meta requires key')
break
}
},
async apply (batch, op, host) {
switch (op.type) {
case 'create-issue': {
// Idempotent — skip if this id already exists. Prevents overwrites
// from replayed ops and supports migration with forceId (an incoming
// create-issue op with an explicit id won't clobber an existing issue).
const existing = await batch.get(`issue:${op.id}`)
if (existing) break
await batch.put(`issue:${op.id}`, {
id: op.id,
title: op.title,
body: op.body || '',
author: op.userId,
status: op.status || 'open',
labels: op.labels || [],
images: op.images || [],
createdAt: op.createdAt || op.timestamp || Date.now(),
updatedAt: op.updatedAt || op.timestamp || Date.now()
})
// Bump the auto-increment counter
const counter = await batch.get('counter:issues')
const currentNext = counter ? counter.value.next : 0
if (op.id >= currentNext) {
await batch.put('counter:issues', { next: op.id + 1 })
}
break
}
case 'update-issue': {
const entry = await batch.get(`issue:${op.id}`)
if (!entry) break
const issue = entry.value
if (op.title !== undefined) issue.title = op.title
if (op.body !== undefined) issue.body = op.body
if (op.status !== undefined) issue.status = op.status
if (op.labels !== undefined) issue.labels = op.labels
if (op.images !== undefined) issue.images = op.images
issue.updatedAt = op.timestamp || Date.now()
await batch.put(`issue:${op.id}`, issue)
break
}
case 'create-comment': {
// Idempotent — skip if this (issueId, commentId) pair exists.
const key = `comment:${op.issueId}:${op.commentId}`
const existing = await batch.get(key)
if (existing) break
await batch.put(key, {
body: op.body,
author: op.userId,
createdAt: op.createdAt || op.timestamp || Date.now()
})
// Bump the issue's updatedAt (skip if we're migrating and don't want to clobber)
if (!op.skipUpdate) {
const issueEntry = await batch.get(`issue:${op.issueId}`)
if (issueEntry) {
issueEntry.value.updatedAt = op.timestamp || Date.now()
await batch.put(`issue:${op.issueId}`, issueEntry.value)
}
}
break
}
case 'update-comment': {
const key = `comment:${op.issueId}:${op.commentId}`
const existing = await batch.get(key)
if (existing) {
existing.value.body = op.body
existing.value.editedAt = op.timestamp || Date.now()
existing.value.editedBy = op.userId
await batch.put(key, existing.value)
}
break
}
case 'delete-comment': {
await batch.del(`comment:${op.issueId}:${op.commentId}`)
break
}
case 'create-label': {
await batch.put(`label:${op.name}`, {
color: op.color || '#ccc',
description: op.description || '',
displayName: op.displayName || op.name
})
break
}
case 'update-label': {
const labelEntry = await batch.get(`label:${op.name}`)
if (labelEntry) {
const label = labelEntry.value
if (op.color !== undefined) label.color = op.color
if (op.description !== undefined) label.description = op.description
if (op.displayName !== undefined) label.displayName = op.displayName
await batch.put(`label:${op.name}`, label)
}
break
}
case 'delete-label': {
await batch.del(`label:${op.name}`)
break
}
case 'update-ref': {
const key = `ref:${op.ref}`
const existing = await batch.get(key)
const currentOid = existing ? existing.value.hash : null
// Fast-forward / compare-and-swap guard: the op declares the oid it
// observed (oldOid). If the view has moved since, reject (non-FF) and
// leave the ref unchanged. Absent ref ⇔ oldOid null/zero-oid.
if (currentOid !== normalizeOldOid(op.oldOid)) break
await batch.put(key, {
hash: op.hash,
updatedBy: op.userId,
timestamp: op.timestamp || Date.now()
})
break
}
case 'delete-ref': {
const key = `ref:${op.ref}`
// If oldOid is declared, guard the delete the same way; an omitted
// oldOid is an unconditional delete (back-compat).
if (op.oldOid != null) {
const existing = await batch.get(key)
const currentOid = existing ? existing.value.hash : null
if (currentOid !== normalizeOldOid(op.oldOid)) break
}
await batch.del(key)
break
}
case 'add-bundle': {
// Bundle chain entry, keyed by the tip oid it produces. Idempotent —
// first writer wins (replay-safe; concurrent writers converge).
const key = `bundle:${op.oid}`
const existing = await batch.get(key)
if (existing) break
await batch.put(key, {
baseOid: op.baseOid || ZERO_OID,
contentHash: op.contentHash,
size: op.size || 0,
ref: op.ref || null,
addedBy: op.userId,
timestamp: op.timestamp || Date.now()
})
break
}
case 'set-meta': {
await batch.put(`meta:${op.key}`, { value: op.value })
break
}
}
}
})
export const DEFAULT_LABELS = [
{ name: 'bug', color: '#d73a4a', description: 'Something isn\'t working' },
{ name: 'enhancement', color: '#a2eeef', description: 'New feature or request' },
{ name: 'documentation', color: '#0075ca', description: 'Improvements or additions to documentation' },
{ name: 'question', color: '#d876e3', description: 'Further information is requested' },
{ name: 'help-wanted', color: '#008672', description: 'Extra attention is needed' },
{ name: 'good-first-issue', color: '#7057ff', description: 'Good for newcomers' },
{ name: 'duplicate', color: '#cfd3d7', description: 'This issue already exists' },
{ name: 'wontfix', color: '#ffffff', description: 'This will not be worked on' }
]