feat: experiment with different implementations of LSEQ
This commit is contained in:
commit
1e45ef9314
23 changed files with 3578 additions and 0 deletions
5
typescript/.npmignore
Normal file
5
typescript/.npmignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
tsconfig.json
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
.gitignore
|
||||
node_modules/
|
||||
61
typescript/README.md
Normal file
61
typescript/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# @peoplesgrocers/lseq
|
||||
|
||||
TypeScript implementation of the L-SEQ algorithm for fractional indexing and list CRDTs.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @peoplesgrocers/lseq
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { LSEQ, compareLSEQ } from '@peoplesgrocers/lseq';
|
||||
|
||||
// Create a new L-SEQ instance
|
||||
const lseq = new LSEQ();
|
||||
|
||||
// Allocate identifiers
|
||||
const id1 = lseq.alloc(null, null); // First identifier
|
||||
const id2 = lseq.alloc(id1, null); // After id1
|
||||
const id3 = lseq.alloc(id1, id2); // Between id1 and id2
|
||||
|
||||
// Sort identifiers
|
||||
const ids = [id3, id1, id2];
|
||||
ids.sort(compareLSEQ);
|
||||
console.log(ids); // [id1, id3, id2] - properly ordered
|
||||
|
||||
// Custom random function (useful for deterministic testing)
|
||||
const deterministicLSEQ = new LSEQ(() => 0.5);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `LSEQ`
|
||||
|
||||
#### `constructor(random?: () => number)`
|
||||
|
||||
Creates a new L-SEQ instance.
|
||||
|
||||
- `random`: Optional custom random function (defaults to `Math.random`)
|
||||
|
||||
#### `alloc(before: string | null, after: string | null): string`
|
||||
|
||||
Allocates a new identifier between two existing identifiers.
|
||||
|
||||
- `before`: The identifier that should come before the new one (or `null` for beginning)
|
||||
- `after`: The identifier that should come after the new one (or `null` for end)
|
||||
- Returns: A new identifier that sorts between `before` and `after`
|
||||
|
||||
### `compareLSEQ(a: string, b: string): number`
|
||||
|
||||
Compares two L-SEQ identifiers for sorting.
|
||||
|
||||
- Returns: `-1` if `a < b`, `1` if `a > b`, `0` if `a === b`
|
||||
|
||||
## How it works
|
||||
|
||||
L-SEQ generates identifiers using a base-64 alphabet that maintains lexicographic ordering. Each identifier is a sequence of characters from this alphabet, and new identifiers are generated by finding space between existing ones at different depths.
|
||||
|
||||
The algorithm uses alternating allocation strategies (bias toward min or max) at different depths to avoid degenerative cases and maintain good performance characteristics.
|
||||
36
typescript/package.json
Normal file
36
typescript/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "@peoplesgrocers/lseq",
|
||||
"version": "1.0.0",
|
||||
"description": "L-SEQ algorithm implementation for fractional indexing and list CRDTs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "uvu -r tsx src \\.test\\.ts$",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"lseq",
|
||||
"crdt",
|
||||
"fractional-indexing",
|
||||
"sequence",
|
||||
"collaborative-editing"
|
||||
],
|
||||
"author": "peoplesgrocers",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"uvu": "^0.5.6",
|
||||
"tsx": "^4.7.0"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"src/**/*",
|
||||
"README.md"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://peoplesgrocers.com/en/forge/peoplesgrocers/lseq.git",
|
||||
"directory": "typescript"
|
||||
}
|
||||
}
|
||||
72
typescript/src/index.ts
Normal file
72
typescript/src/index.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
const ALPHABET =
|
||||
"-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
function idToNumbers(id: string): number[] {
|
||||
const nums = id.split("").map((char) => ALPHABET.indexOf(char));
|
||||
return nums;
|
||||
}
|
||||
|
||||
type Maybe<T> = T | null;
|
||||
|
||||
function isNone<T>(value: Maybe<T>): value is null {
|
||||
return value === null;
|
||||
}
|
||||
|
||||
export class LSEQ {
|
||||
// true = allocate near min, false = allocate near max
|
||||
private strategies: boolean[];
|
||||
private random: () => number;
|
||||
|
||||
constructor(random: () => number = Math.random) {
|
||||
this.random = random;
|
||||
this.strategies = [random() < 0.5];
|
||||
}
|
||||
|
||||
public alloc(before: Maybe<string>, after: Maybe<string>): string {
|
||||
// Convert to numeric arrays, using boundary values for null
|
||||
const p = isNone(before) ? [0] : idToNumbers(before);
|
||||
const q = isNone(after) ? [63] : idToNumbers(after);
|
||||
|
||||
// Walk through digits looking for space
|
||||
let depth = 0;
|
||||
const result = [];
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const pVal = depth < p.length ? p[depth] : 0;
|
||||
const qVal = depth < q.length ? q[depth] : 63;
|
||||
|
||||
const interval = qVal - pVal;
|
||||
|
||||
// If we have space between values at this depth
|
||||
if (interval > 1) {
|
||||
// Pick a value in the available range
|
||||
const range = interval - 1;
|
||||
const addVal = 1 + Math.floor(this.random() * range);
|
||||
let newValue;
|
||||
if (this.strategies[depth]) {
|
||||
newValue = pVal + addVal;
|
||||
} else {
|
||||
newValue = qVal - addVal;
|
||||
}
|
||||
|
||||
// Take the prefix from p up to depth and append our new value
|
||||
result.push(newValue);
|
||||
|
||||
return result.map((n) => ALPHABET[n]).join("");
|
||||
}
|
||||
result.push(pVal);
|
||||
|
||||
// If values are the same or adjacent at this depth,
|
||||
// continue to next depth
|
||||
depth++;
|
||||
if (depth > this.strategies.length) {
|
||||
this.strategies.push(this.random() < 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function compareLSEQ(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
18
typescript/tsconfig.json
Normal file
18
typescript/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue