/**
* Group/namespace apply handler for unified MetadataBase
*
* Extracted from lib/namespace-registry/apply.js
* Processes single group operations within the unified apply batch.
*
* Key renames from original:
* namespace:{name} → group:{name}
* writer:{peerId} → peer:{peerId}
* meta:{key} → group-meta:{key}
*
* Key schema (unified):
* group:{name} → { name, displayName, description, type, userId, members, repos, visibility, createdAt, updatedAt }
* peer:{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, ... }
* user-grants:{userId}:{grantId} → { grantId }
* server-grants:{serverId}:{grantId} → { grantId }
* repo-grants:{repoName}:{grantId} → { grantId }
* group-grants:{groupName}:{grantId} → { grantId }
* group-meta:{key} → { value }
*/
/**
* Apply a single group-namespace operation
*
* @param {object} op - Operation with { ns: 'group', type, ... }
* @param {object} batch - Hyperbee batch (caller flushes)
* @param {object} base - Autobase host (for addWriter/removeWriter)
*/
export async function applyGroupOp (op, batch, base) {
switch (op.type) {
case 'create-namespace': {
// Idempotent — skip if group already exists (handles concurrent create)
const existing = await batch.get(`group:${op.name}`)
if (existing) break
await batch.put(`group:${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(`group:${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(`group:${op.name}`, ns)
break
}
case 'delete-namespace': {
const entry = await batch.get(`group:${op.name}`)
if (!entry) break
// Only delete if repos[] is empty
if (entry.value.repos.length > 0) break
await batch.del(`group:${op.name}`)
break
}
case 'add-repo': {
const nsEntry = await batch.get(`group:${op.namespaceName}`)
if (!nsEntry) break
// Scan all groups to remove repo from previous home
for await (const entry of batch.createReadStream({
gt: 'group:',
lt: 'group:\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(`group:${other.name}`, other)
}
}
// Re-read after potential modifications
const nsRefresh = await batch.get(`group:${op.namespaceName}`)
const ns = nsRefresh.value
if (!ns.repos.includes(op.repoName)) {
ns.repos.push(op.repoName)
ns.updatedAt = op.timestamp
await batch.put(`group:${op.namespaceName}`, ns)
}
break
}
case 'remove-repo': {
const entry = await batch.get(`group:${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(`group:${op.namespaceName}`, ns)
break
}
case 'move-repo': {
const fromEntry = await batch.get(`group:${op.fromNamespace}`)
const toEntry = await batch.get(`group:${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(`group:${op.fromNamespace}`, from)
if (!to.repos.includes(op.repoName)) {
to.repos.push(op.repoName)
}
to.updatedAt = op.timestamp
await batch.put(`group:${op.toNamespace}`, to)
break
}
case 'add-member': {
const entry = await batch.get(`group:${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(`group:${op.namespaceName}`, ns)
break
}
case 'remove-member': {
const entry = await batch.get(`group:${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(`group:${op.namespaceName}`, ns)
break
}
case 'add-writer': {
// Deduplicate: check if a peer with the same serverKey already exists
if (op.serverKey) {
let existingPeerId = null
for await (const entry of batch.createReadStream({ gt: 'peer:', lt: 'peer:\xff' })) {
if (entry.value.serverKey === op.serverKey && !entry.value.removedAt) {
existingPeerId = entry.key.slice('peer:'.length)
break
}
}
// If same serverKey exists under a different peerId, remove the old entry
if (existingPeerId && existingPeerId !== op.peerId) {
await batch.del(`peer:${existingPeerId}`)
}
}
await batch.put(`peer:${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 base.addWriter(Buffer.from(op.serverKey, 'hex'), {
indexer: op.role === 'owner'
})
} catch (err) {
console.error(`[GroupApply] add-writer failed for ${op.serverKey.slice(0, 16)}...: ${err.message}`)
}
}
break
}
case 'remove-writer': {
const existing = await batch.get(`peer:${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(`peer:${op.peerId}`, writerData)
if (writerData.serverKey) {
try {
await base.removeWriter(Buffer.from(writerData.serverKey, 'hex'))
} catch (err) {
console.error(`[GroupApply] 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(`group-meta:${op.key}`, { value: op.value })
break
}
}
}