Skip to content

Commit 418d4ca

Browse files
lodyai[bot]zxch3n
andauthored
feat: add TypeScript fractional index package (#979)
* feat: add TypeScript fractional index package * fix: prevent fractional index byte aliasing * fix: handle zero-prefixed fractional indexes * refactor: expose fractional indexes as strings * ci: install fractional index test deps * chore: use Vite+ tooling for fractional index --------- Co-authored-by: Zixuan Chen <zx@loro.dev>
1 parent cc587ed commit 418d4ca

20 files changed

Lines changed: 3802 additions & 48 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@loro-dev/fractional-index": minor
3+
---
4+
5+
Add a TypeScript fractional index package aligned with the Rust `loro_fractional_index` crate.

.github/workflows/rust.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
bun-version: 1.3.0
3434
- uses: pnpm/action-setup@v4
3535
with:
36-
version: 8
36+
version: 10
3737
- name: Install nextest
3838
uses: taiki-e/install-action@v1
3939
with:

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fractional_index/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ once_cell = { workspace = true }
1919
[dev-dependencies]
2020
fraction_index = { version = "^2.0", package = "fractional_index" }
2121
criterion = "^0.5.0"
22+
serde_json = { workspace = true }
2223

2324

2425
[[bench]]
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
use loro_fractional_index::FractionalIndex;
2+
use rand::{Error, RngCore};
3+
use serde_json::json;
4+
use std::panic::{catch_unwind, set_hook, take_hook, AssertUnwindSafe};
5+
6+
struct ConstantByteRng(u8);
7+
8+
impl RngCore for ConstantByteRng {
9+
fn next_u32(&mut self) -> u32 {
10+
u32::from(self.0) * 0x0101_0101
11+
}
12+
13+
fn next_u64(&mut self) -> u64 {
14+
u64::from(self.0) * 0x0101_0101_0101_0101
15+
}
16+
17+
fn fill_bytes(&mut self, dest: &mut [u8]) {
18+
dest.fill(self.0);
19+
}
20+
21+
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Error> {
22+
self.fill_bytes(dest);
23+
Ok(())
24+
}
25+
}
26+
27+
fn idx(hex: &str) -> FractionalIndex {
28+
FractionalIndex::from_hex_string(hex)
29+
}
30+
31+
fn maybe_hex(value: Option<FractionalIndex>) -> serde_json::Value {
32+
match value {
33+
Some(value) => json!(value.to_string()),
34+
None => serde_json::Value::Null,
35+
}
36+
}
37+
38+
fn maybe_hex_or_panic(value: impl FnOnce() -> Option<FractionalIndex>) -> serde_json::Value {
39+
let hook = take_hook();
40+
set_hook(Box::new(|_| {}));
41+
let result = catch_unwind(AssertUnwindSafe(value));
42+
set_hook(hook);
43+
44+
match result {
45+
Ok(value) => json!({
46+
"panics": false,
47+
"value": maybe_hex(value),
48+
}),
49+
Err(_) => json!({
50+
"panics": true,
51+
"value": null,
52+
}),
53+
}
54+
}
55+
56+
fn evenly(
57+
lower: Option<&FractionalIndex>,
58+
upper: Option<&FractionalIndex>,
59+
n: usize,
60+
) -> serde_json::Value {
61+
match FractionalIndex::generate_n_evenly(lower, upper, n) {
62+
Some(values) => json!(values
63+
.into_iter()
64+
.map(|value| value.to_string())
65+
.collect::<Vec<_>>()),
66+
None => serde_json::Value::Null,
67+
}
68+
}
69+
70+
fn evenly_jitter(
71+
lower: Option<&FractionalIndex>,
72+
upper: Option<&FractionalIndex>,
73+
n: usize,
74+
jitter: u8,
75+
byte: u8,
76+
) -> serde_json::Value {
77+
let mut rng = ConstantByteRng(byte);
78+
match FractionalIndex::generate_n_evenly_jitter(lower, upper, n, &mut rng, jitter) {
79+
Some(values) => json!(values
80+
.into_iter()
81+
.map(|value| value.to_string())
82+
.collect::<Vec<_>>()),
83+
None => serde_json::Value::Null,
84+
}
85+
}
86+
87+
fn main() {
88+
let default = FractionalIndex::default();
89+
let before = FractionalIndex::new_before(&default);
90+
let after = FractionalIndex::new_after(&default);
91+
92+
let mut after_chain = Vec::new();
93+
let mut current = default.clone();
94+
for _ in 0..32 {
95+
current = FractionalIndex::new_after(&current);
96+
after_chain.push(current.to_string());
97+
}
98+
99+
let mut before_chain = Vec::new();
100+
let mut current = default.clone();
101+
for _ in 0..32 {
102+
current = FractionalIndex::new_before(&current);
103+
before_chain.push(current.to_string());
104+
}
105+
106+
let new_cases = vec![
107+
json!({"lower": null, "upper": null, "value": maybe_hex(FractionalIndex::new(None, None))}),
108+
json!({"lower": default.to_string(), "upper": null, "value": maybe_hex(FractionalIndex::new(Some(&default), None))}),
109+
json!({"lower": null, "upper": default.to_string(), "value": maybe_hex(FractionalIndex::new(None, Some(&default)))}),
110+
json!({"lower": before.to_string(), "upper": default.to_string(), "value": maybe_hex(FractionalIndex::new(Some(&before), Some(&default)))}),
111+
json!({"lower": default.to_string(), "upper": after.to_string(), "value": maybe_hex(FractionalIndex::new(Some(&default), Some(&after)))}),
112+
json!({"lower": default.to_string(), "upper": default.to_string(), "value": maybe_hex(FractionalIndex::new(Some(&default), Some(&default)))}),
113+
json!({"lower": after.to_string(), "upper": default.to_string(), "value": maybe_hex(FractionalIndex::new(Some(&after), Some(&default)))}),
114+
];
115+
116+
let between_pairs = [
117+
("80", "8180"),
118+
("7F80", "80"),
119+
("80", "80"),
120+
("8180", "80"),
121+
("7080", "9080"),
122+
("7F80", "8080"),
123+
("8180", "8280"),
124+
("10", "1080"),
125+
("8080", "80"),
126+
("80", "80FF"),
127+
("80FF", "8180"),
128+
("0080", "0180"),
129+
("0080", "008180"),
130+
("FE80", "FF80"),
131+
];
132+
let between = between_pairs
133+
.into_iter()
134+
.map(|(left, right)| {
135+
let left_index = idx(left);
136+
let right_index = idx(right);
137+
let result =
138+
maybe_hex_or_panic(|| FractionalIndex::new_between(&left_index, &right_index));
139+
json!({
140+
"left": left,
141+
"right": right,
142+
"panics": result["panics"],
143+
"value": result["value"],
144+
})
145+
})
146+
.collect::<Vec<_>>();
147+
148+
let mut evenly_cases = Vec::new();
149+
for n in 0..=20 {
150+
evenly_cases.push(json!({
151+
"lower": null,
152+
"upper": null,
153+
"n": n,
154+
"value": evenly(None, None, n),
155+
}));
156+
}
157+
for n in 1..=12 {
158+
evenly_cases.push(json!({
159+
"lower": before.to_string(),
160+
"upper": after.to_string(),
161+
"n": n,
162+
"value": evenly(Some(&before), Some(&after), n),
163+
}));
164+
}
165+
evenly_cases.push(json!({
166+
"lower": default.to_string(),
167+
"upper": default.to_string(),
168+
"n": 3,
169+
"value": evenly(Some(&default), Some(&default), 3),
170+
}));
171+
evenly_cases.push(json!({
172+
"lower": after.to_string(),
173+
"upper": before.to_string(),
174+
"n": 3,
175+
"value": evenly(Some(&after), Some(&before), 3),
176+
}));
177+
178+
let mut rng = ConstantByteRng(0xAB);
179+
let jitter_default_3 = FractionalIndex::jitter_default(&mut rng, 3);
180+
let mut rng = ConstantByteRng(0x01);
181+
let before_jitter = FractionalIndex::new_before_jitter(&default, &mut rng, 2);
182+
let mut rng = ConstantByteRng(0x02);
183+
let after_jitter = FractionalIndex::new_after_jitter(&default, &mut rng, 2);
184+
let mut rng = ConstantByteRng(0x03);
185+
let between_jitter = FractionalIndex::new_between_jitter(&default, &after, &mut rng, 2);
186+
let mut rng = ConstantByteRng(0x04);
187+
let new_jitter_none = FractionalIndex::new_jitter(None, None, &mut rng, 2);
188+
let mut rng = ConstantByteRng(0x05);
189+
let new_jitter_after = FractionalIndex::new_jitter(Some(&default), None, &mut rng, 2);
190+
let mut rng = ConstantByteRng(0x06);
191+
let new_jitter_before = FractionalIndex::new_jitter(None, Some(&default), &mut rng, 2);
192+
193+
let fixture = json!({
194+
"basic": {
195+
"terminator": 128,
196+
"default": default.to_string(),
197+
"beforeDefault": before.to_string(),
198+
"afterDefault": after.to_string(),
199+
"fromHexOddLength": FractionalIndex::from_hex_string("80ffA").to_string(),
200+
},
201+
"chains": {
202+
"after": after_chain,
203+
"before": before_chain,
204+
},
205+
"newCases": new_cases,
206+
"between": between,
207+
"evenly": evenly_cases,
208+
"jitter": {
209+
"defaultJitter0": {
210+
"jitter": 0,
211+
"byte": 0xAB,
212+
"value": FractionalIndex::jitter_default(&mut ConstantByteRng(0xAB), 0).to_string(),
213+
},
214+
"defaultJitter3": {
215+
"jitter": 3,
216+
"byte": 0xAB,
217+
"value": jitter_default_3.to_string(),
218+
},
219+
"before": {
220+
"input": default.to_string(),
221+
"jitter": 2,
222+
"byte": 0x01,
223+
"value": before_jitter.to_string(),
224+
},
225+
"after": {
226+
"input": default.to_string(),
227+
"jitter": 2,
228+
"byte": 0x02,
229+
"value": after_jitter.to_string(),
230+
},
231+
"between": {
232+
"lower": default.to_string(),
233+
"upper": after.to_string(),
234+
"jitter": 2,
235+
"byte": 0x03,
236+
"value": maybe_hex(between_jitter),
237+
},
238+
"newNoneNone": {
239+
"lower": null,
240+
"upper": null,
241+
"jitter": 2,
242+
"byte": 0x04,
243+
"value": maybe_hex(new_jitter_none),
244+
},
245+
"newAfter": {
246+
"lower": default.to_string(),
247+
"upper": null,
248+
"jitter": 2,
249+
"byte": 0x05,
250+
"value": maybe_hex(new_jitter_after),
251+
},
252+
"newBefore": {
253+
"lower": null,
254+
"upper": default.to_string(),
255+
"jitter": 2,
256+
"byte": 0x06,
257+
"value": maybe_hex(new_jitter_before),
258+
},
259+
"generateN": {
260+
"lower": null,
261+
"upper": null,
262+
"n": 7,
263+
"jitter": 1,
264+
"byte": 0xCC,
265+
"value": evenly_jitter(None, None, 7, 1, 0xCC),
266+
}
267+
}
268+
});
269+
270+
println!("{}", serde_json::to_string_pretty(&fixture).unwrap());
271+
}

crates/fractional_index/src/lib.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,20 +78,23 @@ pub(crate) fn new_after(bytes: &[u8]) -> Vec<u8> {
7878
pub(crate) fn new_between(left: &[u8], right: &[u8], extra_capacity: usize) -> Option<Vec<u8>> {
7979
let shorter_len = left.len().min(right.len()) - 1;
8080
for i in 0..shorter_len {
81-
if left[i] < right[i] - 1 {
81+
let left_byte = left[i];
82+
let right_byte = right[i];
83+
let diff = i16::from(right_byte) - i16::from(left_byte);
84+
if diff > 1 {
8285
let mut ans: Vec<u8> = left[0..=i].into();
83-
ans[i] += (right[i] - left[i]) / 2;
86+
ans[i] += (right_byte - left_byte) / 2;
8487
return ans.into();
8588
}
86-
if left[i] == right[i] - 1 {
89+
if diff == 1 {
8790
let (prefix, suffix) = left.split_at(i + 1);
8891
let new_suffix = new_after(suffix);
8992
let mut ans = Vec::with_capacity(prefix.len() + new_suffix.len() + extra_capacity);
9093
ans.extend_from_slice(prefix);
9194
ans.extend_from_slice(&new_suffix);
9295
return ans.into();
9396
}
94-
if left[i] > right[i] {
97+
if diff < 0 {
9598
return None;
9699
}
97100
}
@@ -204,3 +207,19 @@ pub fn bytes_to_hex(bytes: &[u8]) -> String {
204207
output
205208
})
206209
}
210+
211+
#[cfg(test)]
212+
mod tests {
213+
use super::FractionalIndex;
214+
215+
#[test]
216+
fn new_between_handles_zero_shared_prefix() {
217+
let lower = FractionalIndex::from_hex_string("0080");
218+
let upper = FractionalIndex::from_hex_string("008180");
219+
let index = FractionalIndex::new_between(&lower, &upper).unwrap();
220+
221+
assert!(lower < index);
222+
assert!(index < upper);
223+
assert_eq!(index.to_string(), "00817F80");
224+
}
225+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
"check-all": "cargo hack check --each-feature",
99
"build": "cargo build",
1010
"test": "cargo nextest run --features=test_utils,jsonpath --no-fail-fast && cargo test --doc",
11-
"test-all": "pnpm test && pnpm release-wasm && pnpm test-loom",
11+
"test-all": "pnpm test && pnpm test-fractional-index && pnpm release-wasm && pnpm test-loom",
1212
"test-wasm": "cd crates/loro-wasm && pnpm i && pnpm build-dev",
13+
"test-fractional-index": "pnpm --filter @loro-dev/fractional-index install --frozen-lockfile && pnpm --filter @loro-dev/fractional-index check",
1314
"test-loom": "LOOM_MAX_PREEMPTIONS=2 RUSTFLAGS='--cfg loom' cargo test -p loro --test multi_thread_test --release",
1415
"test-loom-reproduce": "LOOM_MAX_PREEMPTIONS=2 LOOM_LOG=info LOOM_LOCATION=1 LOOM_CHECKPOINT_INTERVAL=1 LOOM_CHECKPOINT_FILE=loom_test.json RUSTFLAGS='--cfg loom' cargo test -p loro --test multi_thread_test --release",
1516
"watch-loom": "RUSTFLAGS='--cfg loom' bacon",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
dist/

packages/fractional-index/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2023 Loro
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

0 commit comments

Comments
 (0)