Skip to content

Commit 15df511

Browse files
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
1 parent 20a00a1 commit 15df511

4 files changed

Lines changed: 289 additions & 8 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/**
2+
* Fractional indexing — generate ordering strings that sort lexicographically.
3+
*
4+
* In-house port of David Greenspan's algorithm
5+
* (https://observablehq.com/@dgreensp/implementing-fractional-indexing),
6+
* behavior-identical to the `fractional-indexing` npm package (CC0). A key is a
7+
* variable-length base-62 string: between any two keys there is always room for
8+
* another, so inserts never renumber existing rows. The only cost is gradual
9+
* length growth under repeated same-spot inserts.
10+
*
11+
* A key is `<integer part><fraction>`. The integer part's first character
12+
* encodes its own length (`a..z` → 2..27, `A..Z` → 27..2), letting integers
13+
* grow without bound in both directions. The fraction is plain base-62 digits
14+
* with no trailing zero.
15+
*/
16+
17+
// ---------------------------------------------------------------------------
18+
// Digits
19+
// ---------------------------------------------------------------------------
20+
21+
/** Default digit alphabet. Must be in ascending character-code order. */
22+
export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
23+
24+
// ---------------------------------------------------------------------------
25+
// Integer-part helpers
26+
// ---------------------------------------------------------------------------
27+
28+
/** Length the integer part must have, derived from its first character. */
29+
function getIntegerLength(head: string): number {
30+
if (head >= 'a' && head <= 'z') {
31+
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2
32+
}
33+
if (head >= 'A' && head <= 'Z') {
34+
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2
35+
}
36+
throw new Error(`invalid order key head: ${head}`)
37+
}
38+
39+
function validateInteger(int: string): void {
40+
if (int.length !== getIntegerLength(int[0])) {
41+
throw new Error(`invalid integer part of order key: ${int}`)
42+
}
43+
}
44+
45+
function getIntegerPart(key: string): string {
46+
const integerPartLength = getIntegerLength(key[0])
47+
if (integerPartLength > key.length) {
48+
throw new Error(`invalid order key: ${key}`)
49+
}
50+
return key.slice(0, integerPartLength)
51+
}
52+
53+
function validateOrderKey(key: string, digits: string): void {
54+
if (key === `A${digits[0].repeat(26)}`) {
55+
throw new Error(`invalid order key: ${key}`)
56+
}
57+
// getIntegerPart throws if the head is bad or the key is too short.
58+
const i = getIntegerPart(key)
59+
const f = key.slice(i.length)
60+
if (f.slice(-1) === digits[0]) {
61+
throw new Error(`invalid order key: ${key}`)
62+
}
63+
}
64+
65+
/** Increment the integer part; returns null past the largest integer. */
66+
function incrementInteger(x: string, digits: string): string | null {
67+
validateInteger(x)
68+
const [head, ...digs] = x.split('')
69+
let carry = true
70+
for (let i = digs.length - 1; carry && i >= 0; i--) {
71+
const d = digits.indexOf(digs[i]) + 1
72+
if (d === digits.length) {
73+
digs[i] = digits[0]
74+
} else {
75+
digs[i] = digits[d]
76+
carry = false
77+
}
78+
}
79+
if (carry) {
80+
if (head === 'Z') {
81+
return `a${digits[0]}`
82+
}
83+
if (head === 'z') {
84+
return null
85+
}
86+
const h = String.fromCharCode(head.charCodeAt(0) + 1)
87+
if (h > 'a') {
88+
digs.push(digits[0])
89+
} else {
90+
digs.pop()
91+
}
92+
return h + digs.join('')
93+
}
94+
return head + digs.join('')
95+
}
96+
97+
/** Decrement the integer part; returns null past the smallest integer. */
98+
function decrementInteger(x: string, digits: string): string | null {
99+
validateInteger(x)
100+
const [head, ...digs] = x.split('')
101+
let borrow = true
102+
for (let i = digs.length - 1; borrow && i >= 0; i--) {
103+
const d = digits.indexOf(digs[i]) - 1
104+
if (d === -1) {
105+
digs[i] = digits.slice(-1)
106+
} else {
107+
digs[i] = digits[d]
108+
borrow = false
109+
}
110+
}
111+
if (borrow) {
112+
if (head === 'a') {
113+
return `Z${digits.slice(-1)}`
114+
}
115+
if (head === 'A') {
116+
return null
117+
}
118+
const h = String.fromCharCode(head.charCodeAt(0) - 1)
119+
if (h < 'Z') {
120+
digs.push(digits.slice(-1))
121+
} else {
122+
digs.pop()
123+
}
124+
return h + digs.join('')
125+
}
126+
return head + digs.join('')
127+
}
128+
129+
// ---------------------------------------------------------------------------
130+
// Midpoint
131+
// ---------------------------------------------------------------------------
132+
133+
/**
134+
* Fraction strictly between `a` and `b` (both without integer parts). `a` may be
135+
* empty; `b` is null (open end) or non-empty and `> a`. No trailing zeros.
136+
*/
137+
function midpoint(a: string, b: string | null | undefined, digits: string): string {
138+
const zero = digits[0]
139+
if (b != null && a >= b) {
140+
throw new Error(`${a} >= ${b}`)
141+
}
142+
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
143+
throw new Error('trailing zero')
144+
}
145+
if (b) {
146+
// Strip the longest common prefix, padding `a` with zeros as we go. `b`
147+
// needs no padding — it can't end before `a` within the common prefix.
148+
let n = 0
149+
while ((a[n] || zero) === b[n]) {
150+
n++
151+
}
152+
if (n > 0) {
153+
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits)
154+
}
155+
}
156+
// First digits (or lack thereof) differ.
157+
const digitA = a ? digits.indexOf(a[0]) : 0
158+
const digitB = b != null ? digits.indexOf(b[0]) : digits.length
159+
if (digitB - digitA > 1) {
160+
const midDigit = Math.round(0.5 * (digitA + digitB))
161+
return digits[midDigit]
162+
}
163+
// First digits are consecutive.
164+
if (b && b.length > 1) {
165+
return b.slice(0, 1)
166+
}
167+
// `b` is null or a single digit; recurse into `a`'s tail.
168+
return digits[digitA] + midpoint(a.slice(1), null, digits)
169+
}
170+
171+
// ---------------------------------------------------------------------------
172+
// Public API
173+
// ---------------------------------------------------------------------------
174+
175+
/**
176+
* Returns a key that sorts strictly between `a` and `b`. Either may be null for
177+
* an open end. `a < b` lexicographically when both are non-null.
178+
*
179+
* @throws if `a`/`b` are invalid keys or `a >= b`.
180+
*/
181+
export function generateKeyBetween(
182+
a: string | null | undefined,
183+
b: string | null | undefined,
184+
digits: string = BASE_62_DIGITS
185+
): string {
186+
if (a != null) {
187+
validateOrderKey(a, digits)
188+
}
189+
if (b != null) {
190+
validateOrderKey(b, digits)
191+
}
192+
if (a != null && b != null && a >= b) {
193+
throw new Error(`${a} >= ${b}`)
194+
}
195+
if (a == null) {
196+
if (b == null) {
197+
return `a${digits[0]}`
198+
}
199+
const ib = getIntegerPart(b)
200+
const fb = b.slice(ib.length)
201+
if (ib === `A${digits[0].repeat(26)}`) {
202+
return ib + midpoint('', fb, digits)
203+
}
204+
if (ib < b) {
205+
return ib
206+
}
207+
const res = decrementInteger(ib, digits)
208+
if (res == null) {
209+
throw new Error('cannot decrement any more')
210+
}
211+
return res
212+
}
213+
214+
if (b == null) {
215+
const ia = getIntegerPart(a)
216+
const fa = a.slice(ia.length)
217+
const i = incrementInteger(ia, digits)
218+
return i == null ? ia + midpoint(fa, null, digits) : i
219+
}
220+
221+
const ia = getIntegerPart(a)
222+
const fa = a.slice(ia.length)
223+
const ib = getIntegerPart(b)
224+
const fb = b.slice(ib.length)
225+
if (ia === ib) {
226+
return ia + midpoint(fa, fb, digits)
227+
}
228+
const i = incrementInteger(ia, digits)
229+
if (i == null) {
230+
throw new Error('cannot increment any more')
231+
}
232+
if (i < b) {
233+
return i
234+
}
235+
return ia + midpoint(fa, null, digits)
236+
}
237+
238+
/**
239+
* Returns `n` distinct keys in sorted order, strictly between `a` and `b` (same
240+
* open-end semantics as {@link generateKeyBetween}). When both ends are null,
241+
* returns a contiguous run of "integer" keys.
242+
*/
243+
export function generateNKeysBetween(
244+
a: string | null | undefined,
245+
b: string | null | undefined,
246+
n: number,
247+
digits: string = BASE_62_DIGITS
248+
): string[] {
249+
if (n === 0) {
250+
return []
251+
}
252+
if (n === 1) {
253+
return [generateKeyBetween(a, b, digits)]
254+
}
255+
if (b == null) {
256+
let c = generateKeyBetween(a, b, digits)
257+
const result = [c]
258+
for (let i = 0; i < n - 1; i++) {
259+
c = generateKeyBetween(c, b, digits)
260+
result.push(c)
261+
}
262+
return result
263+
}
264+
if (a == null) {
265+
let c = generateKeyBetween(a, b, digits)
266+
const result = [c]
267+
for (let i = 0; i < n - 1; i++) {
268+
c = generateKeyBetween(a, c, digits)
269+
result.push(c)
270+
}
271+
result.reverse()
272+
return result
273+
}
274+
const mid = Math.floor(n / 2)
275+
const c = generateKeyBetween(a, b, digits)
276+
return [
277+
...generateNKeysBetween(a, c, mid, digits),
278+
c,
279+
...generateNKeysBetween(c, b, n - mid - 1, digits),
280+
]
281+
}

apps/sim/lib/table/order-key.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
* between two rows mints a key strictly between their keys, so no other row's
66
* key changes — insert and delete become O(1) (no position reshift / recompact).
77
*
8-
* Thin wrapper over `fractional-indexing` (Figma/rocicorp algorithm) so the
9-
* implementation is swappable. Keys never run out (variable-length strings);
10-
* the only cost is gradual length growth under repeated same-spot inserts.
8+
* Thin wrapper over the in-house fractional-indexing port (Figma/rocicorp
9+
* algorithm) so the implementation is swappable. Keys never run out
10+
* (variable-length strings); the only cost is gradual length growth under
11+
* repeated same-spot inserts.
1112
*/
1213

13-
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'
14+
import {
15+
generateKeyBetween,
16+
generateNKeysBetween,
17+
} from '@/lib/fractional-indexing/fractional-indexing'
1418

1519
/**
1620
* Returns a key that sorts strictly between `a` and `b`. Pass `null` for an open

apps/sim/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,6 @@
131131
"es-toolkit": "1.45.1",
132132
"ffmpeg-static": "5.3.0",
133133
"fluent-ffmpeg": "2.1.3",
134-
"fractional-indexing": "3.2.0",
135134
"framer-motion": "^12.5.0",
136135
"free-email-domains": "1.2.25",
137136
"google-auth-library": "10.5.0",

bun.lock

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)