/**
* NamespaceManager
*
* Manages named groups (namespaces) for organizing repositories.
* Each namespace is either a 'user' or 'group' type.
* Repos are assigned to exactly one namespace.
*
* Storage schema (Hyperbee):
* namespace:{name} → Namespace object
*/
import { validateName, RESERVED_NAMES } from './reserved-names.js'
export class NamespaceManager {
constructor (options = {}) {
this.registry = options.registry // Hyperbee instance
this.repoRegistry = options.repoRegistry // RepoRegistry instance
}
// ========================================
// Validation
// ========================================
validateName (name) {
return validateName(name)
}
// ========================================
// CRUD
// ========================================
/**
* Create a namespace
* @param {string} name - URL-safe slug
* @param {'user'|'group'} type
* @param {Object} data - { displayName, description, userId (for user type) }
*/
async create (name, type = 'group', data = {}) {
const validation = this.validateName(name)
if (!validation.valid) {
throw new Error(validation.error)
}
if (type !== 'user' && type !== 'group') {
throw new Error('Type must be "user" or "group"')
}
// Check uniqueness
const existing = await this.get(name)
if (existing) {
throw new Error(`Namespace "${name}" already exists`)
}
const namespace = {
name,
displayName: data.displayName || name,
description: data.description || '',
type,
userId: type === 'user' ? (data.userId || null) : null,
members: type === 'group' ? (data.members || []) : [],
repos: [],
visibility: 'public',
createdAt: Date.now(),
updatedAt: Date.now()
}
await this.registry.put(`namespace:${name}`, namespace)
return namespace
}
/**
* Get a namespace by name
* @param {string} name
* @returns {Object|null}
*/
async get (name) {
try {
const entry = await this.registry.get(`namespace:${name}`)
return entry ? entry.value : null
} catch {
return null
}
}
/**
* Update a namespace (partial update)
* @param {string} name
* @param {Object} updates - Fields to update
*/
async update (name, updates) {
const ns = await this.get(name)
if (!ns) {
throw new Error(`Namespace "${name}" not found`)
}
// Only allow updating certain fields
if (updates.displayName !== undefined) ns.displayName = updates.displayName
if (updates.description !== undefined) ns.description = updates.description
if (updates.visibility !== undefined) ns.visibility = updates.visibility
ns.updatedAt = Date.now()
await this.registry.put(`namespace:${name}`, ns)
return ns
}
/**
* Delete a namespace (only if empty)
* @param {string} name
*/
async delete (name) {
const ns = await this.get(name)
if (!ns) {
throw new Error(`Namespace "${name}" not found`)
}
if (ns.repos.length > 0) {
throw new Error(`Cannot delete namespace "${name}": still has ${ns.repos.length} repos`)
}
await this.registry.del(`namespace:${name}`)
}
/**
* List all namespaces
* @param {Object} filters - { type?, visibility? }
* @returns {Array}
*/
async list (filters = {}) {
const namespaces = []
try {
for await (const entry of this.registry.createReadStream({
gt: 'namespace:',
lt: 'namespace:\xff'
})) {
const ns = entry.value
if (filters.type && ns.type !== filters.type) continue
if (filters.visibility && ns.visibility !== filters.visibility) continue
namespaces.push(ns)
}
} catch (err) {
console.error('Error listing namespaces:', err)
}
return namespaces
}
// ========================================
// Repo membership
// ========================================
/**
* Add a repo to a namespace
* @param {string} namespaceName
* @param {string} repoName
*/
async addRepo (namespaceName, repoName) {
const ns = await this.get(namespaceName)
if (!ns) {
throw new Error(`Namespace "${namespaceName}" not found`)
}
if (ns.repos.includes(repoName)) {
return ns // Already in this namespace
}
// Remove from any other namespace first
const allNs = await this.list()
for (const other of allNs) {
if (other.name !== namespaceName && other.repos.includes(repoName)) {
other.repos = other.repos.filter(r => r !== repoName)
other.updatedAt = Date.now()
await this.registry.put(`namespace:${other.name}`, other)
}
}
ns.repos.push(repoName)
ns.updatedAt = Date.now()
await this.registry.put(`namespace:${namespaceName}`, ns)
// Update repo's namespace field in RepoRegistry
if (this.repoRegistry) {
await this.repoRegistry.setNamespace(repoName, namespaceName)
}
return ns
}
/**
* Remove a repo from a namespace
* @param {string} namespaceName
* @param {string} repoName
*/
async removeRepo (namespaceName, repoName) {
const ns = await this.get(namespaceName)
if (!ns) {
throw new Error(`Namespace "${namespaceName}" not found`)
}
if (!ns.repos.includes(repoName)) {
throw new Error(`Repo "${repoName}" is not in namespace "${namespaceName}"`)
}
ns.repos = ns.repos.filter(r => r !== repoName)
ns.updatedAt = Date.now()
await this.registry.put(`namespace:${namespaceName}`, ns)
// Clear repo's namespace field
if (this.repoRegistry) {
await this.repoRegistry.setNamespace(repoName, null)
}
return ns
}
/**
* Move a repo from one namespace to another atomically
* @param {string} repoName
* @param {string} fromNs
* @param {string} toNs
*/
async moveRepo (repoName, fromNs, toNs) {
const from = await this.get(fromNs)
if (!from) throw new Error(`Namespace "${fromNs}" not found`)
const to = await this.get(toNs)
if (!to) throw new Error(`Namespace "${toNs}" not found`)
if (!from.repos.includes(repoName)) {
throw new Error(`Repo "${repoName}" is not in namespace "${fromNs}"`)
}
// Remove from source
from.repos = from.repos.filter(r => r !== repoName)
from.updatedAt = Date.now()
// Add to destination (if not already there)
if (!to.repos.includes(repoName)) {
to.repos.push(repoName)
}
to.updatedAt = Date.now()
// Write both
await this.registry.put(`namespace:${fromNs}`, from)
await this.registry.put(`namespace:${toNs}`, to)
// Update repo's namespace field
if (this.repoRegistry) {
await this.repoRegistry.setNamespace(repoName, toNs)
}
}
// ========================================
// Group members
// ========================================
/**
* Add a member to a group namespace
* @param {string} namespaceName
* @param {string} userId
* @param {'admin'|'member'} role
*/
async addMember (namespaceName, userId, role = 'member') {
const ns = await this.get(namespaceName)
if (!ns) throw new Error(`Namespace "${namespaceName}" not found`)
if (ns.type !== 'group') throw new Error('Can only add members to group namespaces')
if (!['admin', 'member'].includes(role)) {
throw new Error('Role must be "admin" or "member"')
}
const existing = ns.members.findIndex(m => m.userId === userId)
if (existing >= 0) {
// Update role
ns.members[existing].role = role
} else {
ns.members.push({ userId, role, addedAt: Date.now() })
}
ns.updatedAt = Date.now()
await this.registry.put(`namespace:${namespaceName}`, ns)
return ns
}
/**
* Remove a member from a group namespace
* @param {string} namespaceName
* @param {string} userId
*/
async removeMember (namespaceName, userId) {
const ns = await this.get(namespaceName)
if (!ns) throw new Error(`Namespace "${namespaceName}" not found`)
if (ns.type !== 'group') throw new Error('Can only remove members from group namespaces')
ns.members = ns.members.filter(m => m.userId !== userId)
ns.updatedAt = Date.now()
await this.registry.put(`namespace:${namespaceName}`, ns)
return ns
}
/**
* Get all namespaces a user belongs to
* @param {string} userId
* @returns {Array}
*/
async getUserNamespaces (userId) {
const allNs = await this.list()
return allNs.filter(ns => {
if (ns.type === 'user' && ns.userId === userId) return true
if (ns.type === 'group' && ns.members.some(m => m.userId === userId)) return true
return false
})
}
// ========================================
// Startup helpers
// ========================================
/**
* Ensure a default namespace exists (creates if missing)
* @param {string} name
*/
async ensureDefaultNamespace (name = 'local') {
const existing = await this.get(name)
if (existing) return existing
return this.create(name, 'group', {
displayName: name === 'local' ? 'Local' : name,
description: name === 'local' ? 'Uncategorized local repositories' : ''
})
}
/**
* Migrate existing repos into namespaces based on their category field.
* Idempotent — safe to re-run.
* @param {Array} repos - Array of repo objects (with .name and .category)
* @returns {{ created: number, assigned: number, skipped: number }}
*/
async migrateExistingRepos (repos) {
const results = { created: 0, assigned: 0, skipped: 0 }
// Ensure 'local' default namespace exists
await this.ensureDefaultNamespace('local')
// Build set of repos already in any namespace — skip these entirely
const allNs = await this.list()
const assigned = new Set()
for (const ns of allNs) {
for (const r of ns.repos) assigned.add(r)
}
// Only process repos not yet in any namespace
const unassigned = repos.filter(r => !assigned.has(r.name))
if (unassigned.length === 0) {
results.skipped = repos.length
return results
}
// Collect unique categories
const categories = new Map()
for (const repo of unassigned) {
const cat = repo.category || 'uncategorized'
if (!categories.has(cat)) {
categories.set(cat, [])
}
categories.get(cat).push(repo.name)
}
for (const [category, repoNames] of categories) {
// Map category to namespace name (URL-safe slug)
const nsName = category === 'uncategorized'
? 'local'
: category.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-').replace(/^-|-$/g, '')
if (!nsName || RESERVED_NAMES.has(nsName)) {
for (const repoName of repoNames) {
await this.addRepo('local', repoName)
results.assigned++
}
continue
}
// Create namespace if it doesn't exist
let ns = await this.get(nsName)
if (!ns) {
try {
ns = await this.create(nsName, 'group', {
displayName: category,
description: `Repos from category: ${category}`
})
results.created++
} catch {
for (const repoName of repoNames) {
await this.addRepo('local', repoName)
results.assigned++
}
continue
}
}
for (const repoName of repoNames) {
await this.addRepo(nsName, repoName)
results.assigned++
}
}
results.skipped = repos.length - unassigned.length
return results
}
/**
* Reconsolidate groups: move repos to correct groups based on current categories,
* then delete any empty groups.
* @param {Array} repos - Array of repo objects (with .name and .category)
* @returns {{ moved: number, deleted: number }}
*/
async reconsolidateGroups (repos) {
const results = { moved: 0, deleted: 0 }
// Build category map from current repo data
const repoToCategory = new Map()
for (const repo of repos) {
const cat = repo.category || 'uncategorized'
const nsName = cat === 'uncategorized'
? 'local'
: cat.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-').replace(/^-|-$/g, '')
repoToCategory.set(repo.name, nsName || 'local')
}
// Scan all groups and move misplaced repos
const allGroups = await this.list()
for (const group of allGroups) {
for (const repoName of [...group.repos]) {
const correctNs = repoToCategory.get(repoName)
if (!correctNs || correctNs === group.name) continue
// Ensure target group exists
let target = await this.get(correctNs)
if (!target) {
const displayName = correctNs === 'local' ? 'Local' : correctNs
await this.create(correctNs, 'group', {
displayName,
description: `Consolidated repos: ${correctNs}`
})
}
await this.moveRepo(repoName, group.name, correctNs)
results.moved++
}
}
// Delete empty groups (except 'local')
const updatedGroups = await this.list()
for (const group of updatedGroups) {
if (group.name === 'local') continue
if (group.repos.length === 0) {
await this.delete(group.name)
results.deleted++
}
}
return results
}
}