import { generateKeyBetween } from 'fractional-indexing' import { prisma } from './prisma' /** * fractional-indexing 要求按字节序比较 sortKey。 * MySQL 默认 collation(utf8mb4_0900_ai_ci / utf8mb4_unicode_ci)大小写不敏感, * 一旦出现大写字母的 key(例如 `Zz`,它在 fractional-indexing 里 < `a0`), * MySQL ORDER BY 就会给出和 JS 不一致的顺序。 * * 结论:**sortKey 的排序永远在 JS 里做**,不要依赖 DB 的 ORDER BY。 * 这个比较函数就是 JS 原生的字符串 `<` / `>`,对 ASCII 等价于字节序。 */ export function compareSortKey(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0 } /** * 按 sortKey 顺序返回所有关卡(仅取指定字段),排序在 JS 里完成。 * tiebreaker:createdAt 升序(仅用于 sortKey 意外重复时的确定性)。 */ export async function listLevelsOrderedBySortKey< T extends Record >(args: { select: Record excludeId?: string | null }): Promise> { const rows = await prisma.level.findMany({ where: args.excludeId ? { id: { not: args.excludeId } } : undefined, select: { ...args.select, sortKey: true, createdAt: true, }, }) return (rows as Array).sort( (a, b) => { const c = compareSortKey(a.sortKey, b.sortKey) if (c !== 0) return c return a.createdAt.getTime() - b.createdAt.getTime() } ) } /** * 计算追加到末尾的 sortKey:取当前最大 sortKey,生成一个比它更大的。 * 空表场景返回 fractional-indexing 库定义的初始 key。 * * 注意:不能用 `orderBy: { sortKey: 'desc' } findFirst`,因为 MySQL 大小写不敏感 * collation 会错把 `a0` 当成最大,实际 `z...` 才是最大。这里只能拉所有 key 后在 JS 排。 */ export async function keyAfterLast(): Promise { const rows = await prisma.level.findMany({ select: { sortKey: true } }) if (rows.length === 0) { return generateKeyBetween(null, null) } let maxKey = rows[0].sortKey for (let i = 1; i < rows.length; i += 1) { if (compareSortKey(rows[i].sortKey, maxKey) > 0) maxKey = rows[i].sortKey } return generateKeyBetween(maxKey, null) } /** * 用 prevKey / nextKey 计算中间 key;两端任一可为 null(表示头/尾)。 */ export function keyBetween( prevKey: string | null, nextKey: string | null ): string { return generateKeyBetween(prevKey, nextKey) } /** * 全量重平衡:按当前 JS 侧排序给所有行重新分配递增 key。 * * 触发场景: * - 目标位置两侧 key 相等或倒置 * - 手动修复历史 Z 系列 key 带来的 DB/JS 排序分歧 */ export async function rebalanceAllKeys(): Promise { const rows = await prisma.level.findMany({ select: { id: true, sortKey: true, sortOrder: true, createdAt: true }, }) // JS 侧排序:sortKey 字节序 > sortOrder > createdAt rows.sort((a, b) => { const c = compareSortKey(a.sortKey, b.sortKey) if (c !== 0) return c if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder return a.createdAt.getTime() - b.createdAt.getTime() }) let prev: string | null = null for (const r of rows) { const key = generateKeyBetween(prev, null) await prisma.level.update({ where: { id: r.id }, data: { sortKey: key } }) prev = key } return rows.length } /** * position 是 1-based。 * - 创建场景:position 合法范围 [1, total + 1] * - 编辑场景:position 合法范围 [1, total](total 已含当前行,调用方需传 excludeId 排除自己) * * 排序全部在 JS 里做,避免 MySQL collation 导致的 sortKey 顺序错乱。 */ export async function keyForPosition( position: number, excludeId?: string | null ): Promise { const rows = await listLevelsOrderedBySortKey<{ id: string }>({ select: { id: true }, excludeId, }) const prevRow = rows[position - 2] // position=1 时 prev 为 undefined(头) const nextRow = rows[position - 1] // position=rows.length+1 时 next 为 undefined(尾) const prevKey = prevRow?.sortKey ?? null const nextKey = nextRow?.sortKey ?? null if (canComputeBetween(prevKey, nextKey)) { return generateKeyBetween(prevKey, nextKey) } // 兜底:重平衡后再试一次 await rebalanceAllKeys() const retryRows = await listLevelsOrderedBySortKey<{ id: string }>({ select: { id: true }, excludeId, }) const retryPrev = retryRows[position - 2]?.sortKey ?? null const retryNext = retryRows[position - 1]?.sortKey ?? null return generateKeyBetween(retryPrev, retryNext) } function canComputeBetween( prevKey: string | null, nextKey: string | null ): boolean { if (prevKey === null && nextKey === null) return true if (prevKey === null || nextKey === null) return true return compareSortKey(prevKey, nextKey) < 0 }