Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 74 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
# fugue

Fractional indexing without conflicts - based on [Fugue](https://arxiv.org/abs/2305.00583).

998 bytes (minified and brotlied) and no dependencies.
Fractional indexing without conflicts - based on [Fugue](https://arxiv.org/abs/2305.00583). 998 bytes (minified and brotlied) and no dependencies.

Fractional indexing is a technique to create an ordering that can be used for [Realtime Editing of Ordered Sequences](https://www.figma.com/blog/realtime-editing-of-ordered-sequences/).

Heavily based on [position-strings](https://github.com/mweidner037/position-strings), with added support for keys that were created by different libraries (e.g. [`fractional-indexing`](https://github.com/rocicorp/fractional-indexing)).
Heavily based on [position-strings](https://github.com/mweidner037/position-strings) with added support for keys that were previously created by other libraries (e.g. [`fractional-indexing`](https://github.com/rocicorp/fractional-indexing)).

## Motivation

Traditional fractional indexing libraries typically use **deterministic algorithms to generate positions between two points**. This works well in single-user scenarios, but it causes conflicts in distributed systems when multiple users insert items at the same position simultaneously.

> For example, if two users try to insert between keys `a0` and `a2`, a deterministic algorithm would generate the same new position (e.g., `a1`) for both users, causing a conflict. Also, with non-deterministic algorithms in collaborative text applications, when two users concurrently insert text at the same position, these algorithms interleave the inserted text passages, resulting in unreadable content.

Fugue solves this problem by using unique client IDs, ensuring that simultaneous insertions from different clients create distinct, non-interleaving keys. This enables truly conflict-free collaborative editing without requiring any coordination between clients.

## Installation

Expand All @@ -21,16 +27,19 @@ pnpm add fugue
## Usage

First, create an instance of `Fugue`. You need to pass in a `clientID`, which is a
unique identifier for the client. This is used to ensure that positions created by
different clients are distinct and non-interleaving.
unique identifier for the client.

The `clientID` should be a string that is "unique enough for the application". This means that it should be unique _given how many users will be simultaneously creating positions._ In practice, this can be quite small (e.g. `nanoid(6)`).

> The `clientID` should be a string that is unique to the JS runtime. On the server or client,
> this can be created globally with a unique ID (e.g. `nanoid(6)`) and shared across clients.
### Client

On the client size, create a Fugue instance with a unique client ID:

```ts
import { Fugue } from 'fugue';

// created once in the runtime (this would be a unique ID for the client)
// created once in the runtime (this would be a short, unique ID for the client)
// this should be chosen as having enough entropy to be unique across all clients
// it is a good idea to reuse client IDs when possible
export const fugue = new Fugue('client1');
```
Expand Down Expand Up @@ -77,6 +86,61 @@ const middle2 = fugue2.createBetween(first, last); // "client1.B,client2.B"
// these eventually grow to e.g.: client0.B0A0B0aH0B,client1.D,client2.B,client3.L,client4.B,client5.D,client6.B,client7.B,client10.B,client23.B,client35.B,client36.B
```

### Server

When implementing Fugue on a server, you can create a single Fugue instance with a static client ID (e.g. `"server"`) and share it across your server runtime. This approach works well when:

1. You need to generate position keys from server-side logic.
2. Your server operations are coordinated (e.g. through database transactions).

```ts
import { Fugue } from 'fugue';

// Create once and export for use throughout the server
export const fugue = new Fugue('server');

async function insertItemBetween(listId, beforeItemId, afterItemId, itemData) {
// Always use transactions to coordinate between servers
return await db.transaction(async (tx) => {
// If beforeItemId and afterItemId are provided, get their positions
let beforePosition = null;
let afterPosition = null;

// get the position for the before item
const beforeItem = beforeItemId
? await tx
.select({ position: items.position })
.from(items)
.where(and(eq(items.id, beforeItemId), eq(items.listId, listId)))
.get()
: null;

if (beforeItem) beforePosition = beforeItem.position;

// get the position for the after item
const afterItem = afterItemId
? await tx
.select({ position: items.position })
.from(items)
.where(and(eq(items.id, afterItemId), eq(items.listId, listId)))
.get()
: null;

if (afterItem) afterPosition = afterItem.position;

// Generate a position between the two
const position = fugue.createBetween(beforePosition, afterPosition);

// Insert the new item with the generated position
await tx.insert(items).values({
listId,
position,
...itemData,
});
});
}
```

## Features

- Generates unique, ordered keys for inserting items between other items.
Expand All @@ -90,4 +154,4 @@ const middle2 = fugue2.createBetween(first, last); // "client1.B,client2.B"

## Attributions

Thanks to [pgte](https://github.com/pgte) for the NPM package name.
Thanks to [pgte](https://github.com/pgte) for the NPM package name and [Matthew Weidner](https://github.com/mweidner037) for Fugue and `position-strings`.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fugue",
"version": "1.0.0",
"version": "1.0.1",
"description": "Fractional indexing without conflicts.",
"type": "module",
"scripts": {
Expand Down
58 changes: 13 additions & 45 deletions tests/strings.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,7 @@ import { bench, describe } from "vitest";
import { Fugue } from "../src";

describe("benchmarks", () => {
const fugue = new Fugue("test");

bench("simple", () => {
const pos1 = fugue.createBetween(null, null);
const pos2 = fugue.createBetween(pos1, null);
fugue.createBetween(pos1, pos2);
});

bench("fugue", () => {
const pos1 = fugue.createBetween(null, null);
const pos2 = fugue.createBetween(pos1, null);
fugue.createBetween(pos1, pos2);
});

bench("class instantiation", () => {
bench("single", () => {
const internal = new Fugue("test");

const pos1 = internal.createBetween(null, null);
Expand All @@ -26,46 +12,28 @@ describe("benchmarks", () => {

bench("multiple instances", () => {
const instances = Array.from(
{ length: 40 },
{ length: 100 },
(_, i) => new Fugue(`client${i}`),
);
const allPositions: string[] = [];

let firstKey: string | null = null;
let lastKey: string | null = null;

// Create initial position for first instance
const firstInstance = instances[0];
if (firstInstance) {
allPositions.push(firstInstance.createBetween(null, null));
firstKey = firstInstance.createBetween(null, null);
lastKey = firstInstance.createBetween(firstKey, null);
}

// Each instance creates positions between existing positions
for (const instance of instances) {
for (let j = 0; j < 1200; j++) {
// Pick two random existing positions or null
const pos1 =
allPositions.length > 0
? (allPositions[Math.floor(Math.random() * allPositions.length)] ??
null)
: null;
const pos2 =
allPositions.length > 0
? (allPositions[Math.floor(Math.random() * allPositions.length)] ??
null)
: null;

let newPos: string;

if (pos1 === pos2) {
// If the two positions are the same, create a new position at the end
newPos = instance.createBetween(pos1, null);
} else {
// If the two positions are different, create a new position between them
const a = pos1 && pos2 ? (pos2 > pos1 ? pos1 : pos2) : null;
const b = pos1 && pos2 ? (pos2 > pos1 ? pos2 : pos1) : null;
let previousKey: string | null = firstKey;

newPos = instance.createBetween(a, b);
}
for (let j = 0; j < 10; j++) {
for (const instance of instances) {
const newPos = instance.createBetween(previousKey, lastKey);
const newPos2 = instance.createBetween(previousKey, newPos);

allPositions.push(newPos);
previousKey = newPos2;
}
}
});
Expand Down