// @ts-check
import { query, UUID2hex, HEX2uuid, transaction } from '@commtool/sql-query';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';
/**
* Resolves the UIDs to rebuild based on the identifier.
* If the identifier is a known object type, fetches all UIDs of that type ordered by hierarchy.
* If it's a UUID, converts it to hex. Returns an empty array if invalid.
*
* @param {string} identifyer - Object type ('person','group','extern','event','job') or a UUID string
* @returns {Promise<{UIDs: Buffer[], isType: boolean}>}
*/
export async function resolveTreeUIDs(identifyer) {
if (['person', 'group', 'extern', 'event', 'job'].includes(identifyer)) {
// sorting by hierarchie will assure, that we build the tree from the root to all branches
const result = await query(`SELECT DISTINCT ObjectBase.UID, ObjectBase.TUID, ObjectBase.Title FROM
ObjectBase WHERE ObjectBase.Type=? ORDER BY ObjectBase.hierarchie ASC `, [identifyer])
return { UIDs: result.map((r) => (r.UID)), isType: true }
}
else {
const hex = UUID2hex(identifyer)
return { UIDs: hex ? [hex] : [], isType: false }
}
}
/**
* Rebuilds the membership tree for a set of UIDs.
*
* Uses a virtual transaction to compute the desired link state without committing,
* then diffs against the current state and applies only the necessary inserts/deletes.
* This avoids touching existing rows in the system-versioned Links table (updates would
* create unnecessary history entries).
*
* @param {Buffer[]} UIDs - hex Buffer UIDs of the objects to rebuild
* @param {{onProgress?: (status: object) => void}} [options]
* @returns {Promise<{deleted: Array<{UID: Buffer, UIDTarget: Buffer, DisplayMain: string, Display: string}>, added: Array<{UID: Buffer, UIDTarget: Buffer, DisplayMain: string, Display: string, success: boolean}>}>}
*/
export async function rebuildMembershipTree(UIDs, options = {}) {
const onProgress = options.onProgress || (() => { })
// Snapshot the current member links before any changes
const oldSet = await query(`SELECT CONCAT (Main.Title,' ',MainMember.Display) AS DisplayMain, Links.UID,Links.UIDTarget,
CONCAT(ObjectBase.Title,' ',TargetMember.Display) AS Display,
UNIX_TIMESTAMP(Links.validFrom) AS validFrom
FROM Links
INNER JOIN ObjectBase AS Main ON (Main.UID=Links.UID)
INNER JOIN Member AS MainMember ON (MainMember.UID=Main.UIDBelongsTo)
INNER JOIN ObjectBase ON (Links.UIDTarget=ObjectBase.UID AND ObjectBase.Type='group')
INNER JOIN Member AS TargetMember ON (TargetMember.UID=ObjectBase.UIDBelongsTo)
WHERE Links.UID IN (?) AND Links.Type='member'`,
[UIDs]
)
// Compute the desired state via a virtual transaction (rolled back, not committed)
let newSet = []
await transaction(async (connection) => {
let i = 0
const total = UIDs.length
for (const UID of UIDs) {
++i
// Delete existing member links for this UID (only in virtual transaction)
await connection.query(`DELETE Links FROM Links
WHERE Links.Type='member'AND Links.UID IN(?) `, [UID])
onProgress({
action: 'progress',
text: `virtually add actual links for ${HEX2uuid(UID)}`,
current: i,
total,
progress: Math.round((i / total) * 100)
})
// Rebuild member links from memberA -> member/memberA/memberS chain
await connection.query(`INSERT INTO Links (UID,Type,UIDTarget)
(SELECT ObjectBase.UID,'member',gLink.UIDTarget
FROM ObjectBase
INNER JOIN Links AS aLink ON (aLink.UID=ObjectBase.UID AND aLink.Type='memberA')
INNER JOIN Links AS gLink ON (gLink.UID=aLink.UIDTarget AND gLink.Type IN ('member','memberA','memberS'))
LEFT JOIN Links AS existLink ON (existLink.UID=ObjectBase.UID AND existLink.UIDTarget=gLink.UIDTarget
AND existLink.Type ='member')
WHERE ObjectBase.UID IN (?))
ON DUPLICATE KEY UPDATE Type='member'
`, [UID])
}
onProgress({
action: 'progress',
text: `storing the virtually created new links`,
current: total,
total,
progress: 100
})
// Read the computed new state from the virtual transaction
newSet = await connection.query(`
SELECT DISTINCT CONCAT (Main.Title,' ',MainMember.Display) AS DisplayMain, Links.UID,Links.UIDTarget,
CONCAT(ObjectBase.Title,' ',TargetMember.Display) AS Display,
UNIX_TIMESTAMP(aLink.validFrom) AS validFromALink
FROM Links
INNER JOIN ObjectBase AS Main ON (Main.UID=Links.UID)
INNER JOIN Member AS MainMember ON (MainMember.UID=Main.UIDBelongsTo)
INNER JOIN ObjectBase ON (Links.UIDTarget=ObjectBase.UID AND ObjectBase.Type='group')
INNER JOIN Member AS TargetMember ON (TargetMember.UID=ObjectBase.UIDBelongsTo)
INNER JOIN Links AS aLink ON (aLink.UID=Main.UID AND aLink.Type='memberA')
WHERE Links.UID IN (?) AND Links.Type='member'`,
[UIDs]
)
},
{
virtual: true,
beforeTransaction: async (connection) => await connection.query(`SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;`)
})
// Diff: links that exist in oldSet but not in newSet → need to be deleted
const diffDelete = oldSet.filter(old => !newSet.some(newS => (newS.UID.equals(old.UID) && newS.UIDTarget.equals(old.UIDTarget))))
const deleted = []
const deleteTotal = diffDelete.length
for (let i = 0; i < diffDelete.length; i++) {
const old = diffDelete[i]
onProgress({
action: 'progress',
text: `removing invalid link from ${old.DisplayMain} to ${old.Display}`,
current: i + 1,
total: deleteTotal,
progress: Math.round(((i + 1) / Math.max(deleteTotal, 1)) * 100)
})
// backdate the deletion so that the history is fixed as well
let result = await query(`DELETE FROM Links WHERE Links.UID=? And Links.UIDTarget=? AND Links.Type='member'`,
[old.UID, old.UIDTarget]
, { backDate: old.validFrom + 10 })
if (!result) {
// retry without backdating
result = await query(`DELETE FROM Links WHERE Links.UID=? And Links.UIDTarget=? AND Links.Type='member'`,
[old.UID, old.UIDTarget]
)
}
deleted.push({ UID: old.UID, UIDTarget: old.UIDTarget, DisplayMain: old.DisplayMain, Display: old.Display })
}
// Deduplicate newSet – the virtual-txn query can return duplicates when an
// object has multiple memberA links (the JOIN on aLink multiplies rows)
const seen = new Set()
const uniqueNewSet = newSet.filter(row => {
const key = row.UID.toString('hex') + ':' + row.UIDTarget.toString('hex')
if (seen.has(key)) return false
seen.add(key)
return true
})
// Diff: links that exist in newSet but not in oldSet → need to be inserted
const diffAdd = uniqueNewSet.filter(newS => !oldSet.some(old => (old.UID.equals(newS.UID) && old.UIDTarget.equals(newS.UIDTarget))))
const added = []
const addTotal = diffAdd.length
for (let i = 0; i < diffAdd.length; i++) {
const newS = diffAdd[i]
onProgress({
action: 'progress',
text: `adding missing link from ${newS.DisplayMain} to ${newS.Display}`,
current: i + 1,
total: addTotal,
progress: Math.round(((i + 1) / Math.max(addTotal, 1)) * 100)
})
// backdate the insertion so that the history is consistent
// INSERT IGNORE to guard against race conditions (link created between snapshot and apply)
const result = await query(`INSERT IGNORE INTO Links (UID,Type,UIDTarget) VALUES(?,'member',?)`,
[newS.UID, newS.UIDTarget]
, { backDate: newS.validFromALink })
added.push({
UID: newS.UID,
UIDTarget: newS.UIDTarget,
DisplayMain: newS.DisplayMain,
Display: newS.Display,
success: !!result
})
}
return { deleted, added }
}
/**
* High-level rebuild that ensures groups are rebuilt first (sorted by hierarchie ASC),
* so that group-to-group member links are correct before rebuilding persons/jobs/externs
* whose member links depend on the group tree.
*
* @param {string} identifyer - Object type ('person','group','extern','event','job') or a UUID string
* @param {{onProgress?: (status: object) => void}} [options]
* @returns {Promise<{deleted: Array, added: Array}>}
*/
export async function rebuildFullTree(identifyer, options = {}) {
const onProgress = options.onProgress || (() => { })
let allDeleted = []
let allAdded = []
const { UIDs, isType } = await resolveTreeUIDs(identifyer)
if (!UIDs.length) return { deleted: [], added: [] }
// For non-group types, rebuild the group tree first so that
// group→group member links are in place before computing person/job/extern member links
if (isType && identifyer !== 'group') {
const { UIDs: groupUIDs } = await resolveTreeUIDs('group')
if (groupUIDs.length) {
onProgress({ action: 'progress', text: 'rebuilding group tree first…', current: 0, total: groupUIDs.length, progress: 0 })
const groupResult = await rebuildMembershipTree(groupUIDs, { onProgress })
allDeleted.push(...groupResult.deleted)
allAdded.push(...groupResult.added)
}
}
const result = await rebuildMembershipTree(UIDs, { onProgress })
allDeleted.push(...result.deleted)
allAdded.push(...result.added)
return { deleted: allDeleted, added: allAdded }
}