OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues / lib / schema.js

252 lines · javascript ref:
/**
 * 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' }
]