/**
* RepoFacade — LinkedRepo-compatible facade over unified MetadataBase
*
* Implements the exact same public API as LinkedRepo but delegates
* all operations to MetadataBase. Parameterized by repoName.
* Zero changes needed in route files.
*
* Key renames (all scoped by repoName):
* writer:{userId} → repo-writer:{repoName}:{userId}
* ref:{refPath} → repo-ref:{repoName}:{refPath}
* issue:{id} → issue:{repoName}:{id}
* comment:{issueId}:{cid} → comment:{repoName}:{issueId}:{cid}
* label:{name} → label:{repoName}:{name}
* meta:{key} → repo-meta:{repoName}:{key}
* counter:issues → issue-counter:{repoName}
*/
import crypto from 'bare-crypto'
export class RepoFacade {
constructor ({ metadataBase, repoName }) {
this._meta = metadataBase
this.repoName = repoName
this._imageBlobs = null
this._imageRegistry = null
}
// ── Properties (delegate to MetadataBase) ────────────────────
get bootstrapKey () { return this._meta.key }
get bootstrapKeyHex () { return this._meta.bootstrapKeyHex }
get discoveryKey () { return this._meta.discoveryKey }
get localKey () { return this._meta.localKey }
get writable () { return this._meta.writable }
get view () { return this._meta.view }
// Expose _base for wakeup protocol compatibility
get _base () { return this._meta._base }
async ready () {}
// ── Image store ──────────────────────────────────────────────
setImageStore (blobs, imageRegistry) {
this._imageBlobs = blobs
this._imageRegistry = imageRegistry
}
async storeImage (dataUrl) {
if (!this._imageBlobs || !this._imageRegistry) return null
const hash = crypto.createHash('sha256').update(dataUrl).digest('hex').slice(0, 16)
const existing = await this._imageRegistry.get(`image:${hash}`)
if (existing) return hash
const buf = Buffer.from(dataUrl)
const blobId = await this._imageBlobs.put(buf)
await this._imageRegistry.put(`image:${hash}`, { blobId, size: buf.length, storedAt: Date.now() })
return hash
}
async getImage (hash) {
if (!this._imageBlobs || !this._imageRegistry) return null
const entry = await this._imageRegistry.get(`image:${hash}`)
if (!entry) return null
const buf = await this._imageBlobs.get(entry.value.blobId)
return buf ? buf.toString() : null
}
// ── Writer management ────────────────────────────────────────
async addWriter (userId, serverKey, identityKey, proof, role = 'writer') {
await this._meta.append({
v: 2,
ns: 'issue',
type: 'add-writer',
repoName: this.repoName,
userId,
serverKey,
identityKey: identityKey || null,
proof: proof || null,
role,
addedBy: null,
timestamp: Date.now()
})
}
async removeWriter (userId, reason) {
await this._meta.append({
v: 2,
ns: 'issue',
type: 'remove-writer',
repoName: this.repoName,
userId,
reason: reason || null,
timestamp: Date.now()
})
}
async getWriter (userId) {
return this._meta.getByKey(`repo-writer:${this.repoName}:${userId}`)
}
async listWriters () {
await this._meta.update()
const results = []
const prefix = `repo-writer:${this.repoName}:`
for await (const entry of this._meta.view.createReadStream({
gt: prefix,
lt: prefix + '\xff'
})) {
const userId = entry.key.slice(prefix.length)
results.push({ userId, ...entry.value })
}
return results
}
// ── Ref management ───────────────────────────────────────────
async updateRef (ref, hash, userId) {
await this._meta.append({
v: 2,
ns: 'issue',
type: 'update-ref',
repoName: this.repoName,
ref,
hash,
userId,
timestamp: Date.now()
})
}
async deleteRef (ref, userId) {
await this._meta.append({
v: 2,
ns: 'issue',
type: 'delete-ref',
repoName: this.repoName,
ref,
userId,
timestamp: Date.now()
})
}
async getRef (ref) {
return this._meta.getByKey(`repo-ref:${this.repoName}:${ref}`)
}
async listRefs () {
await this._meta.update()
const results = []
const prefix = `repo-ref:${this.repoName}:`
for await (const entry of this._meta.view.createReadStream({
gt: prefix,
lt: prefix + '\xff'
})) {
const ref = entry.key.slice(prefix.length)
results.push({ ref, ...entry.value })
}
return results
}
// ── Issue management ─────────────────────────────────────────
async createIssue (title, body, userId, options = {}) {
await this._meta.update()
const counter = await this._meta.view.get(`issue-counter:${this.repoName}`)
const id = counter ? counter.value.next : 0
await this._meta.append({
v: 2,
ns: 'issue',
type: 'create-issue',
repoName: this.repoName,
id,
title,
body,
userId,
labels: options.labels || [],
images: options.images || [],
timestamp: Date.now()
})
return id
}
async updateIssue (id, updates, userId) {
await this._meta.append({
v: 2,
ns: 'issue',
type: 'update-issue',
repoName: this.repoName,
id,
...updates,
userId,
timestamp: Date.now()
})
}
async createComment (issueId, body, userId) {
const commentId = crypto.randomBytes(8).toString('hex')
await this._meta.append({
v: 2,
ns: 'issue',
type: 'create-comment',
repoName: this.repoName,
issueId,
commentId,
body,
userId,
timestamp: Date.now()
})
return commentId
}
async getIssue (id) {
return this._meta.getByKey(`issue:${this.repoName}:${id}`)
}
async getIssueWithImages (id) {
const issue = await this.getIssue(id)
if (!issue) return null
if (issue.images && issue.images.length > 0 && this._imageRegistry) {
const imageData = {}
for (const hash of issue.images) {
const dataUrl = await this.getImage(hash)
if (dataUrl) imageData[hash] = dataUrl
}
issue.imageData = imageData
}
return issue
}
async listIssues (filters = {}) {
await this._meta.update()
const results = []
const prefix = `issue:${this.repoName}:`
for await (const entry of this._meta.view.createReadStream({
gt: prefix,
lt: prefix + '\xff'
})) {
const issue = entry.value
// Apply filters
if (filters.status && issue.status !== filters.status) continue
if (filters.author && issue.author !== filters.author) continue
if (filters.labels && filters.labels.length > 0) {
const hasLabel = filters.labels.some(l => issue.labels && issue.labels.includes(l))
if (!hasLabel) continue
}
if (filters.search) {
const q = filters.search.toLowerCase()
const inTitle = issue.title && issue.title.toLowerCase().includes(q)
const inBody = issue.body && issue.body.toLowerCase().includes(q)
if (!inTitle && !inBody) continue
}
results.push(issue)
}
// Sort
const sort = filters.sort || 'newest'
if (sort === 'newest') {
results.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
} else if (sort === 'oldest') {
results.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0))
} else if (sort === 'updated') {
results.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
}
return results
}
async getIssueCounts () {
await this._meta.update()
let open = 0; let closed = 0
const prefix = `issue:${this.repoName}:`
for await (const entry of this._meta.view.createReadStream({
gt: prefix,
lt: prefix + '\xff'
})) {
if (entry.value.status === 'open') open++
else closed++
}
return { open, closed, total: open + closed }
}
async getComments (issueId) {
await this._meta.update()
const results = []
const prefix = `comment:${this.repoName}:${issueId}:`
for await (const entry of this._meta.view.createReadStream({
gt: prefix,
lt: prefix + '\xff'
})) {
const commentId = entry.key.slice(prefix.length)
results.push({ commentId, ...entry.value })
}
return results
}
// ── Label management ─────────────────────────────────────────
async createLabel (name, color, description, displayName) {
await this._meta.append({
v: 2,
ns: 'issue',
type: 'create-label',
repoName: this.repoName,
name,
color: color || '#ccc',
description: description || '',
displayName: displayName || name
})
}
async updateLabel (name, updates) {
await this._meta.append({
v: 2,
ns: 'issue',
type: 'update-label',
repoName: this.repoName,
name,
...updates
})
}
async deleteLabel (name) {
await this._meta.append({
v: 2,
ns: 'issue',
type: 'delete-label',
repoName: this.repoName,
name
})
}
async getLabel (name) {
const normalized = name.toLowerCase().trim()
const val = await this._meta.getByKey(`label:${this.repoName}:${normalized}`)
if (!val) return null
return { name: normalized, ...val }
}
async listLabels () {
await this._meta.update()
const results = []
const prefix = `label:${this.repoName}:`
for await (const entry of this._meta.view.createReadStream({
gt: prefix,
lt: prefix + '\xff'
})) {
const name = entry.key.slice(prefix.length)
results.push({ name, ...entry.value })
}
results.sort((a, b) => a.name.localeCompare(b.name))
return results
}
async initializeDefaultLabels () {
const existing = await this.listLabels()
if (existing.length > 0) return existing
const defaults = [
{ 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: 'good first issue', color: '#7057ff', description: 'Good for newcomers' },
{ name: 'help wanted', color: '#008672', description: 'Extra attention is needed' },
{ name: 'question', color: '#d876e3', description: 'Further information is requested' }
]
for (const label of defaults) {
await this.createLabel(label.name, label.color, label.description)
}
return this.listLabels()
}
async addLabelsToIssue (id, labelNames, userId) {
await this._meta.update()
const issue = await this.getIssue(id)
if (!issue) return null
// Verify labels exist
const validLabels = []
for (const name of labelNames) {
const label = await this.getLabel(name)
if (label) validLabels.push(name.toLowerCase().trim())
}
const merged = [...new Set([...(issue.labels || []), ...validLabels])]
await this.updateIssue(id, { labels: merged }, userId)
return this.getIssue(id)
}
async removeLabelsFromIssue (id, labelNames, userId) {
await this._meta.update()
const issue = await this.getIssue(id)
if (!issue) return null
const removeSet = new Set(labelNames.map(n => n.toLowerCase().trim()))
const filtered = (issue.labels || []).filter(l => !removeSet.has(l))
await this.updateIssue(id, { labels: filtered }, userId)
return this.getIssue(id)
}
async setIssueLabels (id, labelNames, userId) {
// Verify labels exist
const validLabels = []
for (const name of labelNames) {
const label = await this.getLabel(name)
if (label) validLabels.push(name.toLowerCase().trim())
}
await this.updateIssue(id, { labels: validLabels }, userId)
return this.getIssue(id)
}
// ── Meta ─────────────────────────────────────────────────────
async setMeta (key, value) {
await this._meta.append({
v: 2,
ns: 'issue',
type: 'set-meta',
repoName: this.repoName,
key,
value
})
}
async getMeta (key) {
const val = await this._meta.getByKey(`repo-meta:${this.repoName}:${key}`)
return val ? val.value : null
}
// ── Lifecycle ────────────────────────────────────────────────
async replicate (stream) {
return stream
}
async update () {
await this._meta.update()
}
async close () {
// MetadataBase owns the Autobase lifecycle
}
}