Skip to content

Commit 2d0771a

Browse files
krisnyeclaude
andauthored
feat(schema): add FractionalIndex type namespace (#105)
Ports the Greenspan/Figma base-62 fractional-indexing algorithm from the horizon codebase as a clean @adobe/data namespace. Exposes FractionalIndex.schema (usable as an ECS component), FractionalIndex.initial, FractionalIndex.between(a, b), and FractionalIndex.betweenN(a, b, n). Algorithm lives in per-file private helpers; 76 tests cover the full spec including length-growth properties for bulk and sequential inserts. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f7e9e75 commit 2d0771a

22 files changed

Lines changed: 511 additions & 0 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import { describe, it, expect } from "vitest";
3+
import { betweenN } from "./between-n.js";
4+
import type { FractionalIndex } from "./fractional-index.js";
5+
6+
const fi = (s: string) => s as FractionalIndex;
7+
8+
describe("betweenN", () => {
9+
it("returns empty array for n = 0", () => {
10+
expect(betweenN(undefined, undefined, 0)).toEqual([]);
11+
});
12+
13+
it("generates n keys between undefined bounds", () => {
14+
expect(betweenN(undefined, undefined, 5).join(" ")).toBe("a0 a1 a2 a3 a4");
15+
});
16+
17+
it("generates n keys after a lower bound", () => {
18+
expect(betweenN(fi("a4"), undefined, 10).join(" ")).toBe(
19+
"a5 a6 a7 a8 a9 aA aB aC aD aE",
20+
);
21+
});
22+
23+
it("generates n keys before an upper bound", () => {
24+
expect(betweenN(undefined, fi("a0"), 5).join(" ")).toBe("Zv Zw Zx Zy Zz");
25+
});
26+
27+
it("generates n keys between two bounds (shared integer part)", () => {
28+
expect(betweenN(fi("a0"), fi("a2"), 20).join(" ")).toBe(
29+
"a04 a08 a0G a0K a0O a0V a0Z a0d a0l a0t a1 a14 a18 a1G a1O a1V a1Z a1d a1l a1t",
30+
);
31+
});
32+
33+
it("keys are in strictly ascending order", () => {
34+
const keys = betweenN(fi("a0"), fi("b00"), 50);
35+
for (let i = 1; i < keys.length; i++) {
36+
expect(keys[i] > keys[i - 1]).toBe(true);
37+
}
38+
});
39+
40+
it("bulk insert of 500 keys grows length by at most 2 chars", () => {
41+
const start = fi("a0");
42+
const end = betweenN(start, undefined, 1)[0];
43+
const keys = betweenN(start, end, 500);
44+
const baseline = Math.max(start.length, end.length);
45+
const maxLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
46+
expect(maxLen).toBe(baseline + 2);
47+
});
48+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import type { FractionalIndex } from "./fractional-index.js";
3+
import { keyBetween } from "./key-between.js";
4+
5+
export const betweenN = (
6+
a: FractionalIndex | undefined,
7+
b: FractionalIndex | undefined,
8+
n: number,
9+
): FractionalIndex[] => {
10+
if (n === 0) return [];
11+
if (n === 1) return [keyBetween(a, b)];
12+
13+
if (b === undefined) {
14+
let c = keyBetween(a, b);
15+
const result = [c];
16+
for (let i = 0; i < n - 1; i++) {
17+
c = keyBetween(c, b);
18+
result.push(c);
19+
}
20+
return result;
21+
}
22+
23+
if (a === undefined) {
24+
let c = keyBetween(a, b);
25+
const result = [c];
26+
for (let i = 0; i < n - 1; i++) {
27+
c = keyBetween(a, c);
28+
result.push(c);
29+
}
30+
result.reverse();
31+
return result;
32+
}
33+
34+
const mid = Math.floor(n / 2);
35+
const c = keyBetween(a, b);
36+
return [
37+
...betweenN(a, c, mid),
38+
c,
39+
...betweenN(c, b, n - mid - 1),
40+
];
41+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import { describe, it, expect } from "vitest";
3+
import { between } from "./between.js";
4+
import type { FractionalIndex } from "./fractional-index.js";
5+
6+
const fi = (s: string) => s as FractionalIndex;
7+
8+
describe("between", () => {
9+
it("returns initial when both bounds are undefined", () => {
10+
expect(between(undefined, undefined)).toBe("a0");
11+
});
12+
13+
it("generates keys before an existing key", () => {
14+
expect(between(undefined, fi("a0"))).toBe("Zz");
15+
expect(between(undefined, fi("Y00"))).toBe("Xzzz");
16+
expect(between(undefined, fi("a0V"))).toBe("a0");
17+
expect(between(undefined, fi("b999"))).toBe("b99");
18+
expect(between(undefined, fi("A000000000000000000000000001"))).toBe("A000000000000000000000000000V");
19+
});
20+
21+
it("generates keys after an existing key", () => {
22+
expect(between(fi("a0"), undefined)).toBe("a1");
23+
expect(between(fi("bzz"), undefined)).toBe("c000");
24+
expect(between(fi("zzzzzzzzzzzzzzzzzzzzzzzzzzy"), undefined)).toBe("zzzzzzzzzzzzzzzzzzzzzzzzzzz");
25+
expect(between(fi("zzzzzzzzzzzzzzzzzzzzzzzzzzz"), undefined)).toBe("zzzzzzzzzzzzzzzzzzzzzzzzzzzV");
26+
});
27+
28+
it("generates keys between two existing keys", () => {
29+
expect(between(fi("a0"), fi("a1"))).toBe("a0V");
30+
expect(between(fi("a0V"), fi("a1"))).toBe("a0l");
31+
expect(between(fi("Zz"), fi("a0"))).toBe("ZzV");
32+
expect(between(fi("Zz"), fi("a1"))).toBe("a0");
33+
expect(between(fi("a0"), fi("a0V"))).toBe("a0G");
34+
expect(between(fi("a0"), fi("a0G"))).toBe("a08");
35+
expect(between(fi("b125"), fi("b129"))).toBe("b127");
36+
expect(between(fi("a0"), fi("a1V"))).toBe("a1");
37+
expect(between(fi("Zz"), fi("a01"))).toBe("a0");
38+
});
39+
40+
it("throws for invalid key (smallest integer)", () => {
41+
expect(() => between(undefined, fi("A00000000000000000000000000"))).toThrow("invalid order key");
42+
});
43+
44+
it("throws for trailing zero in fractional part", () => {
45+
expect(() => between(undefined, fi("b0"))).toThrow("invalid order key");
46+
expect(() => between(fi("a00"), undefined)).toThrow("invalid order key");
47+
expect(() => between(fi("a00"), fi("a1"))).toThrow("invalid order key");
48+
});
49+
50+
it("throws when head is a digit instead of a letter", () => {
51+
expect(() => between(fi("0"), fi("1"))).toThrow("Invalid order key head");
52+
});
53+
54+
it("throws when a >= b", () => {
55+
expect(() => between(fi("a1"), fi("a0"))).toThrow("Invalid key order: a >= b");
56+
});
57+
58+
it("max length for 100k consecutive appends is 4 chars", () => {
59+
let current: FractionalIndex = fi("a0");
60+
let maxLen = current.length;
61+
for (let i = 0; i < 100_000; i++) {
62+
current = between(current, undefined);
63+
if (current.length > maxLen) maxLen = current.length;
64+
}
65+
expect(maxLen).toBe(4);
66+
});
67+
68+
it("max length for 500 consecutive head-inserts is 86 chars", () => {
69+
let next: FractionalIndex | undefined;
70+
let maxLen = 0;
71+
for (let i = 0; i < 500; i++) {
72+
next = between(fi("a0"), next);
73+
if (next.length > maxLen) maxLen = next.length;
74+
}
75+
expect(maxLen).toBe(86);
76+
});
77+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import type { FractionalIndex } from "./fractional-index.js";
3+
import { keyBetween } from "./key-between.js";
4+
5+
export const between = (
6+
a: FractionalIndex | undefined,
7+
b: FractionalIndex | undefined,
8+
): FractionalIndex => keyBetween(a, b);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import { describe, it, expect } from "vitest";
3+
import { decrementInteger } from "./decrement-integer.js";
4+
5+
describe("decrementInteger", () => {
6+
it("decrements simple keys", () => {
7+
expect(decrementInteger("a1")).toBe("a0");
8+
expect(decrementInteger("a2")).toBe("a1");
9+
});
10+
11+
it("borrows across digit positions", () => {
12+
expect(decrementInteger("b00")).toBe("az");
13+
expect(decrementInteger("b10")).toBe("b0z");
14+
expect(decrementInteger("b20")).toBe("b1z");
15+
expect(decrementInteger("c000")).toBe("bzz");
16+
expect(decrementInteger("dAC00")).toBe("dABzz");
17+
});
18+
19+
it("crosses the positive/negative boundary", () => {
20+
expect(decrementInteger("Zz")).toBe("Zy");
21+
expect(decrementInteger("a0")).toBe("Zz");
22+
});
23+
24+
it("handles transitions that lengthen the integer part (negative side)", () => {
25+
expect(decrementInteger("Yzz")).toBe("Yzy");
26+
expect(decrementInteger("Z0")).toBe("Yzz");
27+
expect(decrementInteger("Xz00")).toBe("Xyzz");
28+
expect(decrementInteger("Xz01")).toBe("Xz00");
29+
expect(decrementInteger("Y00")).toBe("Xzzz");
30+
});
31+
32+
it("returns undefined at the bottom of range", () => {
33+
expect(decrementInteger("A00000000000000000000000000")).toBeUndefined();
34+
});
35+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import { digits } from "./digits.js";
3+
import { validateInteger } from "./validate-integer.js";
4+
5+
export const decrementInteger = (x: string): string | undefined => {
6+
validateInteger(x);
7+
const [head, ...digs] = x.split("");
8+
let borrow = true;
9+
for (let i = digs.length - 1; borrow && i >= 0; i--) {
10+
const d = digits.indexOf(digs[i]) - 1;
11+
if (d === -1) {
12+
digs[i] = digits.slice(-1);
13+
} else {
14+
digs[i] = digits.charAt(d);
15+
borrow = false;
16+
}
17+
}
18+
if (borrow) {
19+
if (head === "a") return "Z" + digits.slice(-1);
20+
if (head === "A") return undefined;
21+
const h = String.fromCharCode(head.charCodeAt(0) - 1);
22+
if (h < "Z") digs.push(digits.slice(-1)); else digs.pop();
23+
return h + digs.join("");
24+
}
25+
return head + digs.join("");
26+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
export const digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import { ToType } from "../to-type.js";
3+
import { schema } from "./schema.js";
4+
5+
export type FractionalIndex = ToType<typeof schema>;
6+
export * as FractionalIndex from "./public.js";
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import { describe, it, expect } from "vitest";
3+
import { incrementInteger } from "./increment-integer.js";
4+
5+
describe("incrementInteger", () => {
6+
it("increments simple keys", () => {
7+
expect(incrementInteger("a0")).toBe("a1");
8+
expect(incrementInteger("a1")).toBe("a2");
9+
});
10+
11+
it("carries across digit positions", () => {
12+
expect(incrementInteger("az")).toBe("b00");
13+
expect(incrementInteger("b0z")).toBe("b10");
14+
expect(incrementInteger("b1z")).toBe("b20");
15+
expect(incrementInteger("bzz")).toBe("c000");
16+
expect(incrementInteger("dABzz")).toBe("dAC00");
17+
});
18+
19+
it("crosses the negative/positive boundary", () => {
20+
expect(incrementInteger("Zy")).toBe("Zz");
21+
expect(incrementInteger("Zz")).toBe("a0");
22+
});
23+
24+
it("handles transitions that shorten the integer part (negative side)", () => {
25+
expect(incrementInteger("Yzy")).toBe("Yzz");
26+
expect(incrementInteger("Yzz")).toBe("Z0");
27+
expect(incrementInteger("Xyzz")).toBe("Xz00");
28+
expect(incrementInteger("Xz00")).toBe("Xz01");
29+
expect(incrementInteger("Xzzz")).toBe("Y00");
30+
});
31+
32+
it("returns undefined at the top of range", () => {
33+
expect(incrementInteger("zzzzzzzzzzzzzzzzzzzzzzzzzzz")).toBeUndefined();
34+
});
35+
36+
it("throws on invalid integer length", () => {
37+
expect(() => incrementInteger("b0")).toThrow("invalid integer part of order key");
38+
});
39+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// © 2026 Adobe. MIT License. See /LICENSE for details.
2+
import { digits } from "./digits.js";
3+
import { validateInteger } from "./validate-integer.js";
4+
5+
export const incrementInteger = (x: string): string | undefined => {
6+
validateInteger(x);
7+
const [head, ...digs] = x.split("");
8+
let carry = true;
9+
for (let i = digs.length - 1; carry && i >= 0; i--) {
10+
const d = digits.indexOf(digs[i]) + 1;
11+
if (d === digits.length) {
12+
digs[i] = "0";
13+
} else {
14+
digs[i] = digits.charAt(d);
15+
carry = false;
16+
}
17+
}
18+
if (carry) {
19+
if (head === "Z") return "a0";
20+
if (head === "z") return undefined;
21+
const h = String.fromCharCode(head.charCodeAt(0) + 1);
22+
if (h > "a") digs.push("0"); else digs.pop();
23+
return h + digs.join("");
24+
}
25+
return head + digs.join("");
26+
};

0 commit comments

Comments
 (0)