mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
chore(tables): own fractional-indexing in-house, drop runtime dep (#4900)
* chore(tables): own fractional-indexing in-house, drop runtime dep * chore(tables): fully remove fractional-indexing dependency and differential test
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Fractional indexing — generate ordering strings that sort lexicographically.
|
||||
*
|
||||
* In-house port of David Greenspan's algorithm
|
||||
* (https://observablehq.com/@dgreensp/implementing-fractional-indexing),
|
||||
* behavior-identical to the `fractional-indexing` npm package (CC0). A key is a
|
||||
* variable-length base-62 string: between any two keys there is always room for
|
||||
* another, so inserts never renumber existing rows. The only cost is gradual
|
||||
* length growth under repeated same-spot inserts.
|
||||
*
|
||||
* A key is `<integer part><fraction>`. The integer part's first character
|
||||
* encodes its own length (`a..z` → 2..27, `A..Z` → 27..2), letting integers
|
||||
* grow without bound in both directions. The fraction is plain base-62 digits
|
||||
* with no trailing zero.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Digits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default digit alphabet. Must be in ascending character-code order. */
|
||||
export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integer-part helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Length the integer part must have, derived from its first character. */
|
||||
function getIntegerLength(head: string): number {
|
||||
if (head >= 'a' && head <= 'z') {
|
||||
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2
|
||||
}
|
||||
if (head >= 'A' && head <= 'Z') {
|
||||
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2
|
||||
}
|
||||
throw new Error(`invalid order key head: ${head}`)
|
||||
}
|
||||
|
||||
function validateInteger(int: string): void {
|
||||
if (int.length !== getIntegerLength(int[0])) {
|
||||
throw new Error(`invalid integer part of order key: ${int}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getIntegerPart(key: string): string {
|
||||
const integerPartLength = getIntegerLength(key[0])
|
||||
if (integerPartLength > key.length) {
|
||||
throw new Error(`invalid order key: ${key}`)
|
||||
}
|
||||
return key.slice(0, integerPartLength)
|
||||
}
|
||||
|
||||
function validateOrderKey(key: string, digits: string): void {
|
||||
if (key === `A${digits[0].repeat(26)}`) {
|
||||
throw new Error(`invalid order key: ${key}`)
|
||||
}
|
||||
// getIntegerPart throws if the head is bad or the key is too short.
|
||||
const i = getIntegerPart(key)
|
||||
const f = key.slice(i.length)
|
||||
if (f.slice(-1) === digits[0]) {
|
||||
throw new Error(`invalid order key: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Increment the integer part; returns null past the largest integer. */
|
||||
function incrementInteger(x: string, digits: string): string | null {
|
||||
validateInteger(x)
|
||||
const [head, ...digs] = x.split('')
|
||||
let carry = true
|
||||
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
||||
const d = digits.indexOf(digs[i]) + 1
|
||||
if (d === digits.length) {
|
||||
digs[i] = digits[0]
|
||||
} else {
|
||||
digs[i] = digits[d]
|
||||
carry = false
|
||||
}
|
||||
}
|
||||
if (carry) {
|
||||
if (head === 'Z') {
|
||||
return `a${digits[0]}`
|
||||
}
|
||||
if (head === 'z') {
|
||||
return null
|
||||
}
|
||||
const h = String.fromCharCode(head.charCodeAt(0) + 1)
|
||||
if (h > 'a') {
|
||||
digs.push(digits[0])
|
||||
} else {
|
||||
digs.pop()
|
||||
}
|
||||
return h + digs.join('')
|
||||
}
|
||||
return head + digs.join('')
|
||||
}
|
||||
|
||||
/** Decrement the integer part; returns null past the smallest integer. */
|
||||
function decrementInteger(x: string, digits: string): string | null {
|
||||
validateInteger(x)
|
||||
const [head, ...digs] = x.split('')
|
||||
let borrow = true
|
||||
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
||||
const d = digits.indexOf(digs[i]) - 1
|
||||
if (d === -1) {
|
||||
digs[i] = digits.slice(-1)
|
||||
} else {
|
||||
digs[i] = digits[d]
|
||||
borrow = false
|
||||
}
|
||||
}
|
||||
if (borrow) {
|
||||
if (head === 'a') {
|
||||
return `Z${digits.slice(-1)}`
|
||||
}
|
||||
if (head === 'A') {
|
||||
return null
|
||||
}
|
||||
const h = String.fromCharCode(head.charCodeAt(0) - 1)
|
||||
if (h < 'Z') {
|
||||
digs.push(digits.slice(-1))
|
||||
} else {
|
||||
digs.pop()
|
||||
}
|
||||
return h + digs.join('')
|
||||
}
|
||||
return head + digs.join('')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Midpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fraction strictly between `a` and `b` (both without integer parts). `a` may be
|
||||
* empty; `b` is null (open end) or non-empty and `> a`. No trailing zeros.
|
||||
*/
|
||||
function midpoint(a: string, b: string | null | undefined, digits: string): string {
|
||||
const zero = digits[0]
|
||||
if (b != null && a >= b) {
|
||||
throw new Error(`${a} >= ${b}`)
|
||||
}
|
||||
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
|
||||
throw new Error('trailing zero')
|
||||
}
|
||||
if (b) {
|
||||
// Strip the longest common prefix, padding `a` with zeros as we go. `b`
|
||||
// needs no padding — it can't end before `a` within the common prefix.
|
||||
let n = 0
|
||||
while ((a[n] || zero) === b[n]) {
|
||||
n++
|
||||
}
|
||||
if (n > 0) {
|
||||
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits)
|
||||
}
|
||||
}
|
||||
// First digits (or lack thereof) differ.
|
||||
const digitA = a ? digits.indexOf(a[0]) : 0
|
||||
const digitB = b != null ? digits.indexOf(b[0]) : digits.length
|
||||
if (digitB - digitA > 1) {
|
||||
const midDigit = Math.round(0.5 * (digitA + digitB))
|
||||
return digits[midDigit]
|
||||
}
|
||||
// First digits are consecutive.
|
||||
if (b && b.length > 1) {
|
||||
return b.slice(0, 1)
|
||||
}
|
||||
// `b` is null or a single digit; recurse into `a`'s tail.
|
||||
return digits[digitA] + midpoint(a.slice(1), null, digits)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a key that sorts strictly between `a` and `b`. Either may be null for
|
||||
* an open end. `a < b` lexicographically when both are non-null.
|
||||
*
|
||||
* @throws if `a`/`b` are invalid keys or `a >= b`.
|
||||
*/
|
||||
export function generateKeyBetween(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
digits: string = BASE_62_DIGITS
|
||||
): string {
|
||||
if (a != null) {
|
||||
validateOrderKey(a, digits)
|
||||
}
|
||||
if (b != null) {
|
||||
validateOrderKey(b, digits)
|
||||
}
|
||||
if (a != null && b != null && a >= b) {
|
||||
throw new Error(`${a} >= ${b}`)
|
||||
}
|
||||
if (a == null) {
|
||||
if (b == null) {
|
||||
return `a${digits[0]}`
|
||||
}
|
||||
const ib = getIntegerPart(b)
|
||||
const fb = b.slice(ib.length)
|
||||
if (ib === `A${digits[0].repeat(26)}`) {
|
||||
return ib + midpoint('', fb, digits)
|
||||
}
|
||||
if (ib < b) {
|
||||
return ib
|
||||
}
|
||||
const res = decrementInteger(ib, digits)
|
||||
if (res == null) {
|
||||
throw new Error('cannot decrement any more')
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
if (b == null) {
|
||||
const ia = getIntegerPart(a)
|
||||
const fa = a.slice(ia.length)
|
||||
const i = incrementInteger(ia, digits)
|
||||
return i == null ? ia + midpoint(fa, null, digits) : i
|
||||
}
|
||||
|
||||
const ia = getIntegerPart(a)
|
||||
const fa = a.slice(ia.length)
|
||||
const ib = getIntegerPart(b)
|
||||
const fb = b.slice(ib.length)
|
||||
if (ia === ib) {
|
||||
return ia + midpoint(fa, fb, digits)
|
||||
}
|
||||
const i = incrementInteger(ia, digits)
|
||||
if (i == null) {
|
||||
throw new Error('cannot increment any more')
|
||||
}
|
||||
if (i < b) {
|
||||
return i
|
||||
}
|
||||
return ia + midpoint(fa, null, digits)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `n` distinct keys in sorted order, strictly between `a` and `b` (same
|
||||
* open-end semantics as {@link generateKeyBetween}). When both ends are null,
|
||||
* returns a contiguous run of "integer" keys.
|
||||
*/
|
||||
export function generateNKeysBetween(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
n: number,
|
||||
digits: string = BASE_62_DIGITS
|
||||
): string[] {
|
||||
if (n === 0) {
|
||||
return []
|
||||
}
|
||||
if (n === 1) {
|
||||
return [generateKeyBetween(a, b, digits)]
|
||||
}
|
||||
if (b == null) {
|
||||
let c = generateKeyBetween(a, b, digits)
|
||||
const result = [c]
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
c = generateKeyBetween(c, b, digits)
|
||||
result.push(c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (a == null) {
|
||||
let c = generateKeyBetween(a, b, digits)
|
||||
const result = [c]
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
c = generateKeyBetween(a, c, digits)
|
||||
result.push(c)
|
||||
}
|
||||
result.reverse()
|
||||
return result
|
||||
}
|
||||
const mid = Math.floor(n / 2)
|
||||
const c = generateKeyBetween(a, b, digits)
|
||||
return [
|
||||
...generateNKeysBetween(a, c, mid, digits),
|
||||
c,
|
||||
...generateNKeysBetween(c, b, n - mid - 1, digits),
|
||||
]
|
||||
}
|
||||
@@ -5,12 +5,16 @@
|
||||
* between two rows mints a key strictly between their keys, so no other row's
|
||||
* key changes — insert and delete become O(1) (no position reshift / recompact).
|
||||
*
|
||||
* Thin wrapper over `fractional-indexing` (Figma/rocicorp algorithm) so the
|
||||
* implementation is swappable. Keys never run out (variable-length strings);
|
||||
* the only cost is gradual length growth under repeated same-spot inserts.
|
||||
* Thin wrapper over the in-house fractional-indexing port (Figma/rocicorp
|
||||
* algorithm) so the implementation is swappable. Keys never run out
|
||||
* (variable-length strings); the only cost is gradual length growth under
|
||||
* repeated same-spot inserts.
|
||||
*/
|
||||
|
||||
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'
|
||||
import {
|
||||
generateKeyBetween,
|
||||
generateNKeysBetween,
|
||||
} from '@/lib/fractional-indexing/fractional-indexing'
|
||||
|
||||
/**
|
||||
* Returns a key that sorts strictly between `a` and `b`. Pass `null` for an open
|
||||
|
||||
@@ -131,7 +131,6 @@
|
||||
"es-toolkit": "1.45.1",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"fractional-indexing": "3.2.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"free-email-domains": "1.2.25",
|
||||
"google-auth-library": "10.5.0",
|
||||
|
||||
@@ -185,7 +185,6 @@
|
||||
"es-toolkit": "1.45.1",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"fractional-indexing": "3.2.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"free-email-domains": "1.2.25",
|
||||
"google-auth-library": "10.5.0",
|
||||
@@ -2491,8 +2490,6 @@
|
||||
|
||||
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
|
||||
|
||||
"fractional-indexing": ["fractional-indexing@3.2.0", "", {}, "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
||||
|
||||
"free-email-domains": ["free-email-domains@1.2.25", "", {}, "sha512-Uf2rJUjo/agIgQzt6od9XcHrR6rfIMD6TwsNVSJVJCHzjPWWsqjCb+EaQ2VVVY9M55+JB3V0k6ru5sHTGx/ZfA=="],
|
||||
|
||||
Reference in New Issue
Block a user