OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / scripts / old-apply / namespace-apply.js

462 lines · javascript ref:
/**
 * Deterministic Apply Function for Namespace Registry Autobase
 *
 * Processes linearized operations and updates the Hyperbee view.
 * This function MUST be fully deterministic — it may be called
 * multiple times as Autobase reorders causal forks.
 *
 * View schema (Hyperbee keys):
 *   namespace:{name}    → { name, displayName, description, type, userId, members, repos, visibility, createdAt, updatedAt }
 *   writer:{peerId}     → { role, serverKey, addedBy, addedAt }
 *   grant:{grantId}     → { grantId, subjectType, subjectId, grantType, groupName, repoName, permission, ... }
 *   invite:{inviteId}   → { inviteId, inviteType, targetGroup, targetRepo, permission, maxUses, ... }
 *   request:{requestId} → { requestId, subjectType, subjectId, requestType, groupName, repoName, permission, status, ... }
 *   meta:{key}          → { value }
 */

import { decodeOp } from './namespace-operations.js'

/**
 * Apply function for Autobase
 *
 * @param {Array} nodes - Linearized operation nodes
 * @param {Hyperbee} view - Hyperbee view to update
 * @param {Object} host - Autobase host (for addWriter/removeWriter)
 */
export async function apply (nodes, view, host) {
  const batch = view.batch()

  for (const node of nodes) {
    const op = decodeOp(node.value)

    switch (op.type) {
      case 'create-namespace': {
        // Idempotent — skip if namespace already exists (handles concurrent create)
        const existing = await batch.get(`namespace:${op.name}`)
        if (existing) break

        await batch.put(`namespace:${op.name}`, {
          name: op.name,
          displayName: op.displayName || op.name,
          description: op.description || '',
          type: op.nsType,
          userId: op.nsType === 'user' ? (op.userId || null) : null,
          members: op.members || [],
          repos: [],
          visibility: op.visibility || 'public',
          createdAt: op.timestamp,
          updatedAt: op.timestamp
        })
        break
      }

      case 'update-namespace': {
        const entry = await batch.get(`namespace:${op.name}`)
        if (!entry) break

        const ns = entry.value
        if (op.displayName !== undefined) ns.displayName = op.displayName
        if (op.description !== undefined) ns.description = op.description
        if (op.visibility !== undefined) ns.visibility = op.visibility
        ns.updatedAt = op.timestamp

        await batch.put(`namespace:${op.name}`, ns)
        break
      }

      case 'delete-namespace': {
        const entry = await batch.get(`namespace:${op.name}`)
        if (!entry) break

        // Only delete if repos[] is empty
        if (entry.value.repos.length > 0) break

        await batch.del(`namespace:${op.name}`)
        break
      }

      case 'add-repo': {
        const nsEntry = await batch.get(`namespace:${op.namespaceName}`)
        if (!nsEntry) break

        // Scan all namespaces to remove repo from previous home
        for await (const entry of batch.createReadStream({
          gt: 'namespace:',
          lt: 'namespace:\xff'
        })) {
          const other = entry.value
          if (other.name !== op.namespaceName && other.repos.includes(op.repoName)) {
            other.repos = other.repos.filter(r => r !== op.repoName)
            other.updatedAt = op.timestamp
            await batch.put(`namespace:${other.name}`, other)
          }
        }

        // Re-read after potential modifications
        const nsRefresh = await batch.get(`namespace:${op.namespaceName}`)
        const ns = nsRefresh.value

        if (!ns.repos.includes(op.repoName)) {
          ns.repos.push(op.repoName)
          ns.updatedAt = op.timestamp
          await batch.put(`namespace:${op.namespaceName}`, ns)
        }
        break
      }

      case 'remove-repo': {
        const entry = await batch.get(`namespace:${op.namespaceName}`)
        if (!entry) break

        const ns = entry.value
        ns.repos = ns.repos.filter(r => r !== op.repoName)
        ns.updatedAt = op.timestamp
        await batch.put(`namespace:${op.namespaceName}`, ns)
        break
      }

      case 'move-repo': {
        const fromEntry = await batch.get(`namespace:${op.fromNamespace}`)
        const toEntry = await batch.get(`namespace:${op.toNamespace}`)
        if (!fromEntry || !toEntry) break

        const from = fromEntry.value
        const to = toEntry.value

        from.repos = from.repos.filter(r => r !== op.repoName)
        from.updatedAt = op.timestamp
        await batch.put(`namespace:${op.fromNamespace}`, from)

        if (!to.repos.includes(op.repoName)) {
          to.repos.push(op.repoName)
        }
        to.updatedAt = op.timestamp
        await batch.put(`namespace:${op.toNamespace}`, to)
        break
      }

      case 'add-member': {
        const entry = await batch.get(`namespace:${op.namespaceName}`)
        if (!entry) break

        const ns = entry.value
        const idx = ns.members.findIndex(m => m.userId === op.memberId)
        if (idx >= 0) {
          // Upsert — update role
          ns.members[idx].role = op.role
        } else {
          ns.members.push({
            userId: op.memberId,
            role: op.role,
            addedAt: op.timestamp
          })
        }
        ns.updatedAt = op.timestamp
        await batch.put(`namespace:${op.namespaceName}`, ns)
        break
      }

      case 'remove-member': {
        const entry = await batch.get(`namespace:${op.namespaceName}`)
        if (!entry) break

        const ns = entry.value
        ns.members = ns.members.filter(m => m.userId !== op.memberId)
        ns.updatedAt = op.timestamp
        await batch.put(`namespace:${op.namespaceName}`, ns)
        break
      }

      case 'add-writer': {
        // Deduplicate: check if a writer with the same serverKey already exists
        if (op.serverKey) {
          let existingPeerId = null
          for await (const entry of batch.createReadStream({ gt: 'writer:', lt: 'writer:\xff' })) {
            if (entry.value.serverKey === op.serverKey && !entry.value.removedAt) {
              existingPeerId = entry.key.slice('writer:'.length)
              break
            }
          }
          // If same serverKey exists under a different peerId, remove the old entry
          if (existingPeerId && existingPeerId !== op.peerId) {
            await batch.del(`writer:${existingPeerId}`)
          }
        }

        await batch.put(`writer:${op.peerId}`, {
          role: op.role,
          serverKey: op.serverKey,
          addedBy: op.addedBy || null,
          addedAt: op.timestamp || Date.now()
        })
        if (op.serverKey && op.role !== 'mirror') {
          try {
            await host.addWriter(Buffer.from(op.serverKey, 'hex'), {
              indexer: op.role === 'owner'
            })
          } catch (err) {
            console.error(`[NamespaceRegistry:apply] add-writer failed for ${op.serverKey.slice(0, 16)}...: ${err.message}`)
          }
        }
        break
      }

      case 'remove-writer': {
        const existing = await batch.get(`writer:${op.peerId}`)
        if (existing) {
          const writerData = existing.value
          // Mark as removed instead of deleting — allows peers to detect revocation
          writerData.removedAt = op.timestamp || Date.now()
          await batch.put(`writer:${op.peerId}`, writerData)
          if (writerData.serverKey) {
            try {
              await host.removeWriter(Buffer.from(writerData.serverKey, 'hex'))
            } catch (err) {
              console.error(`[NamespaceRegistry:apply] remove-writer failed for ${writerData.serverKey.slice(0, 16)}...: ${err.message}`)
            }
          }
        }
        break
      }

      case 'create-grant': {
        const grantKey = `grant:${op.grantId}`
        const existing = await batch.get(grantKey)
        if (existing) break // idempotent

        const grant = {
          grantId: op.grantId,
          subjectType: op.subjectType,
          subjectId: op.subjectId,
          grantType: op.grantType,
          groupName: op.groupName || null,
          repoName: op.repoName || null,
          permission: op.permission,
          grantedBy: op.grantedBy || null,
          grantedAt: op.timestamp,
          revokedAt: null,
          revokedBy: null,
          inviteId: op.inviteId || null
        }

        await batch.put(grantKey, grant)

        // Build indexes for efficient lookups
        if (op.subjectType === 'user') {
          await batch.put(`user-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`server-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
        }

        if (op.grantType === 'repo') {
          await batch.put(`repo-grants:${op.repoName}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`group-grants:${op.groupName}:${op.grantId}`, { grantId: op.grantId })
        }
        break
      }

      case 'revoke-grant': {
        const grantKey = `grant:${op.grantId}`
        const entry = await batch.get(grantKey)
        if (!entry) break

        const grant = entry.value
        if (grant.revokedAt) break // already revoked

        grant.revokedAt = op.timestamp
        grant.revokedBy = op.revokedBy || null
        await batch.put(grantKey, grant)
        break
      }

      case 'create-invite': {
        const inviteKey = `invite:${op.inviteId}`
        const existing = await batch.get(inviteKey)
        if (existing) break // idempotent

        await batch.put(inviteKey, {
          inviteId: op.inviteId,
          inviteType: op.inviteType,
          targetGroup: op.targetGroup || null,
          targetRepo: op.targetRepo || null,
          permission: op.permission,
          maxUses: op.maxUses || 1,
          usedCount: 0,
          createdBy: op.createdBy || null,
          createdAt: op.timestamp,
          expiresAt: op.expiresAt || null,
          revoked: false
        })
        break
      }

      case 'consume-invite': {
        const inviteKey = `invite:${op.inviteId}`
        const inviteEntry = await batch.get(inviteKey)
        if (!inviteEntry) break

        const invite = inviteEntry.value
        if (invite.revoked) break
        if (invite.maxUses > 0 && invite.usedCount >= invite.maxUses) break
        if (invite.expiresAt && op.timestamp > invite.expiresAt) break

        // Increment usage
        invite.usedCount++
        await batch.put(inviteKey, invite)

        // Create the grant from the invite
        const grantKey = `grant:${op.grantId}`
        const existingGrant = await batch.get(grantKey)
        if (existingGrant) break // idempotent

        const grant = {
          grantId: op.grantId,
          subjectType: op.subjectType,
          subjectId: op.subjectId,
          grantType: invite.inviteType,
          groupName: invite.targetGroup || null,
          repoName: invite.targetRepo || null,
          permission: invite.permission,
          grantedBy: invite.createdBy || null,
          grantedAt: op.timestamp,
          revokedAt: null,
          revokedBy: null,
          inviteId: op.inviteId
        }

        await batch.put(grantKey, grant)

        // Build indexes
        if (op.subjectType === 'user') {
          await batch.put(`user-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`server-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
        }

        if (invite.inviteType === 'repo') {
          await batch.put(`repo-grants:${invite.targetRepo}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`group-grants:${invite.targetGroup}:${op.grantId}`, { grantId: op.grantId })
        }
        break
      }

      case 'revoke-invite': {
        const inviteKey = `invite:${op.inviteId}`
        const entry = await batch.get(inviteKey)
        if (!entry) break

        const invite = entry.value
        invite.revoked = true
        await batch.put(inviteKey, invite)
        break
      }

      case 'create-access-request': {
        const reqKey = `request:${op.requestId}`
        const existing = await batch.get(reqKey)
        if (existing) break // idempotent

        await batch.put(reqKey, {
          requestId: op.requestId,
          subjectType: op.subjectType,
          subjectId: op.subjectId,
          requestType: op.requestType,
          groupName: op.groupName || null,
          repoName: op.repoName || null,
          permission: op.permission,
          reason: op.reason || null,
          status: 'pending',
          createdAt: op.timestamp,
          respondedAt: null,
          respondedBy: null,
          denyReason: null
        })

        // Build indexes
        const target = op.requestType === 'group' ? op.groupName : op.repoName
        await batch.put(`${op.requestType}-requests:${target}:${op.requestId}`, { requestId: op.requestId })
        if (op.subjectType === 'user') {
          await batch.put(`user-requests:${op.subjectId}:${op.requestId}`, { requestId: op.requestId })
        } else {
          await batch.put(`server-requests:${op.subjectId}:${op.requestId}`, { requestId: op.requestId })
        }
        break
      }

      case 'approve-access-request': {
        const reqKey = `request:${op.requestId}`
        const reqEntry = await batch.get(reqKey)
        if (!reqEntry) break
        const request = reqEntry.value
        if (request.status !== 'pending') break

        // Mark request as approved
        request.status = 'approved'
        request.respondedAt = op.timestamp
        request.respondedBy = op.approvedBy || null
        await batch.put(reqKey, request)

        // Create the grant from the request
        const grantKey = `grant:${op.grantId}`
        const existingGrant = await batch.get(grantKey)
        if (existingGrant) break

        const grant = {
          grantId: op.grantId,
          subjectType: request.subjectType,
          subjectId: request.subjectId,
          grantType: request.requestType,
          groupName: request.groupName || null,
          repoName: request.repoName || null,
          permission: request.permission,
          grantedBy: op.approvedBy || null,
          grantedAt: op.timestamp,
          revokedAt: null,
          revokedBy: null,
          requestId: op.requestId
        }

        await batch.put(grantKey, grant)

        // Build grant indexes
        if (request.subjectType === 'user') {
          await batch.put(`user-grants:${request.subjectId}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`server-grants:${request.subjectId}:${op.grantId}`, { grantId: op.grantId })
        }

        if (request.requestType === 'repo') {
          await batch.put(`repo-grants:${request.repoName}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`group-grants:${request.groupName}:${op.grantId}`, { grantId: op.grantId })
        }
        break
      }

      case 'deny-access-request': {
        const reqKey = `request:${op.requestId}`
        const reqEntry = await batch.get(reqKey)
        if (!reqEntry) break
        const request = reqEntry.value
        if (request.status !== 'pending') break

        request.status = 'denied'
        request.respondedAt = op.timestamp
        request.respondedBy = op.deniedBy || null
        request.denyReason = op.denyReason || null
        await batch.put(reqKey, request)
        break
      }

      case 'set-meta': {
        await batch.put(`meta:${op.key}`, { value: op.value })
        break
      }
    }
  }

  await batch.flush()
}