OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / local-namespace-manager.js

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