Add tenant merge dry-run planner

This commit is contained in:
2026-08-06 12:24:43 +02:00
parent 13936db100
commit f691e3db33
2 changed files with 205 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
import { createHash } from "crypto"
export type TenantMergeKind = "import" | "existing" | "conflict" | "id_collision"
export type TenantMergeDecision = "source" | "target"
export type TenantMergeTableMetadata = {
primaryKey: string[]
}
export type TenantMergeItem = {
id: string
table: string
kind: TenantMergeKind
sourceKey: string
targetKey: string | null
label: string
defaultDecision: TenantMergeDecision
allowedDecisions: TenantMergeDecision[]
differences: string[]
sourceRow: Record<string, any>
targetRow: Record<string, any> | null
}
export type TenantMergePlan = {
items: TenantMergeItem[]
summary: Record<TenantMergeKind, number>
}
const ignoredComparisonColumns = new Set([
"created_at",
"createdAt",
"updated_at",
"updatedAt",
"updated_by",
"updatedBy",
])
const normalizeText = (value: unknown) => String(value ?? "").trim().toLocaleLowerCase("de")
const naturalKeyColumns: Record<string, string[]> = {
accounts: ["accountChart", "number"],
units: ["name"],
countrys: ["name"],
citys: ["zip", "short", "districtCode"],
}
const comparableRow = (row: Record<string, any>) => Object.fromEntries(
Object.entries(row)
.filter(([column]) => !ignoredComparisonColumns.has(column))
.sort(([left], [right]) => left.localeCompare(right))
)
const rowDifferences = (source: Record<string, any>, target: Record<string, any>) => {
const sourceComparable = comparableRow(source)
const targetComparable = comparableRow(target)
const columns = Array.from(new Set([
...Object.keys(sourceComparable),
...Object.keys(targetComparable),
])).sort()
return columns.filter((column) =>
JSON.stringify(sourceComparable[column]) !== JSON.stringify(targetComparable[column])
)
}
const rowKey = (row: Record<string, any>, columns: string[]) =>
columns.map((column) => `${column}=${JSON.stringify(row[column] ?? null)}`).join("|")
const naturalKey = (table: string, row: Record<string, any>) => {
const columns = naturalKeyColumns[table]
if (!columns) return null
const values = columns.map((column) => row[column])
if (values.every((value) => value === null || typeof value === "undefined" || value === "")) return null
return columns.map((column, index) => `${column}=${normalizeText(values[index])}`).join("|")
}
const itemId = (table: string, sourceKey: string) =>
createHash("sha256").update(`${table}\0${sourceKey}`).digest("hex").slice(0, 24)
const itemLabel = (table: string, row: Record<string, any>, fallback: string) => {
const descriptive = row.name ?? row.label ?? row.title ?? row.number ?? row.email ?? row.filename
return descriptive ? `${descriptive}` : `${table}: ${fallback}`
}
export const buildTenantMergePlan = (
sourceTables: Record<string, Record<string, any>[]>,
targetTables: Record<string, Record<string, any>[]>,
metadata: Record<string, TenantMergeTableMetadata>
): TenantMergePlan => {
const items: TenantMergeItem[] = []
for (const table of Object.keys(sourceTables).sort()) {
const primaryKey = metadata[table]?.primaryKey?.length ? metadata[table].primaryKey : ["id"]
const targetRows = targetTables[table] || []
const targetByPrimaryKey = new Map(targetRows.map((row) => [rowKey(row, primaryKey), row]))
const targetByNaturalKey = new Map<string, Record<string, any>>()
for (const targetRow of targetRows) {
const key = naturalKey(table, targetRow)
if (key) targetByNaturalKey.set(key, targetRow)
}
for (const sourceRow of sourceTables[table] || []) {
const sourceKey = rowKey(sourceRow, primaryKey)
const sourceNaturalKey = naturalKey(table, sourceRow)
const naturalTarget = sourceNaturalKey ? targetByNaturalKey.get(sourceNaturalKey) : undefined
const primaryTarget = targetByPrimaryKey.get(sourceKey)
const targetRow = naturalTarget || primaryTarget || null
const differences = targetRow ? rowDifferences(sourceRow, targetRow) : []
let kind: TenantMergeKind
if (!targetRow) {
kind = "import"
} else if (!differences.length) {
kind = "existing"
} else if (primaryTarget && sourceNaturalKey && naturalKey(table, primaryTarget) !== sourceNaturalKey) {
kind = "id_collision"
} else {
kind = "conflict"
}
items.push({
id: itemId(table, sourceKey),
table,
kind,
sourceKey,
targetKey: targetRow ? rowKey(targetRow, primaryKey) : null,
label: itemLabel(table, sourceRow, sourceKey),
defaultDecision: kind === "import" || kind === "id_collision" ? "source" : "target",
allowedDecisions: kind === "conflict" ? ["target", "source"] : [kind === "existing" ? "target" : "source"],
differences,
sourceRow,
targetRow,
})
}
}
return {
items,
summary: {
import: items.filter((item) => item.kind === "import").length,
existing: items.filter((item) => item.kind === "existing").length,
conflict: items.filter((item) => item.kind === "conflict").length,
id_collision: items.filter((item) => item.kind === "id_collision").length,
},
}
}