145 lines
4.9 KiB
TypeScript
145 lines
4.9 KiB
TypeScript
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<string, unknown>
|
||
>(args: {
|
||
select: Record<string, boolean>
|
||
excludeId?: string | null
|
||
}): Promise<Array<T & { sortKey: string; createdAt: Date }>> {
|
||
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<T & { sortKey: string; createdAt: Date }>).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<string> {
|
||
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<number> {
|
||
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<string> {
|
||
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
|
||
}
|