feat: experiment with different implementations of LSEQ

This commit is contained in:
nobody 2025-07-08 16:49:52 -07:00
commit 1e45ef9314
Signed by: GrocerPublishAgent
GPG key ID: D460CD54A9E3AB86
23 changed files with 3578 additions and 0 deletions

5
typescript/.npmignore Normal file
View file

@ -0,0 +1,5 @@
tsconfig.json
*.test.ts
*.spec.ts
.gitignore
node_modules/

61
typescript/README.md Normal file
View 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
View 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
View 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
View 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"]
}