diff --git a/apps/sim/lib/fractional-indexing/fractional-indexing.ts b/apps/sim/lib/fractional-indexing/fractional-indexing.ts new file mode 100644 index 0000000000..7eab3d5912 --- /dev/null +++ b/apps/sim/lib/fractional-indexing/fractional-indexing.ts @@ -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 ``. 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), + ] +} diff --git a/apps/sim/lib/table/order-key.ts b/apps/sim/lib/table/order-key.ts index 49c6385707..2f701badf9 100644 --- a/apps/sim/lib/table/order-key.ts +++ b/apps/sim/lib/table/order-key.ts @@ -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 diff --git a/apps/sim/package.json b/apps/sim/package.json index c9df40e689..29c196333e 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -132,7 +132,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", diff --git a/bun.lock b/bun.lock index 2a293215c7..eb65c533e9 100644 --- a/bun.lock +++ b/bun.lock @@ -186,7 +186,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", @@ -2500,8 +2499,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=="],