Files
MemeStudio/lib/sort-key.ts
2026-05-01 08:44:56 +08:00

145 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { generateKeyBetween } from 'fractional-indexing'
import { prisma } from './prisma'
/**
* fractional-indexing 要求按字节序比较 sortKey。
* MySQL 默认 collationutf8mb4_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 里完成。
* tiebreakercreatedAt 升序(仅用于 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
}