Last active
December 4, 2024 22:06
-
-
Save gvergnaud/1b8987922b10ef936ddb7efe110e4d29 to your computer and use it in GitHub Desktop.
a Moveable Tree CRDT, built on top of Yjs's Y.Map.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Moveable tree. | |
* | |
* This implementation uses fractional indexing for children positions | |
* and a parent id on the children for descendance. | |
* | |
* Pros: | |
* - move operations are last-writer-wins. | |
* - move operations can't produce duplicates. | |
* - Can be built on top of existing last-writer-wins map crdts. | |
* | |
* Cons: | |
* - Cycles are possible in theory (but could be ignored or resolved | |
* automatically when serializing, and shouldn't happen in practice). | |
* | |
*/ | |
import groupBy from 'lodash/groupBy'; | |
import mapValues from 'lodash/mapValues'; | |
import zip from 'lodash/zip'; | |
import { v4 } from 'uuid'; | |
import type { FractionalIndex } from '../fractional-index'; | |
import { | |
fractionalIndexAsc, | |
generateKeyBetween, | |
generateNKeysBetween, | |
} from '../fractional-index'; | |
import type { YObject } from '../y-object'; | |
import { createYObject, unwrapYObject } from '../y-object'; | |
type Id = string; | |
export type Tree<T> = { id: Id; value: T; children: Tree<T>[] }; | |
/** | |
* An opaque branded type, only updatable using the `YMoveableTree` | |
* namespace. | |
*/ | |
const brand = Symbol.for('@crdt/brand'); | |
export type YMoveableTree<T> = { | |
[brand]: [name: '@crdts/YMoveableTree', valueType: T]; | |
}; | |
/** | |
* The internal representation of the moveable tree: | |
*/ | |
type YMoveableTreeInternal<T> = YObject<{ | |
root: YObject<{ id: Id; data: T }>; | |
// nodes are flatten out. | |
nodes: YObject<Record<Id, YObject<MoveableTreeNode<T>>>>; | |
}>; | |
type MoveableTree<T> = { | |
root: { id: Id; data: T }; | |
nodes: Record<Id, MoveableTreeNode<T>>; | |
}; | |
type MoveableTreeNode<T> = { | |
// the position of a node is just it's parent | |
// id and a fractionalIndex for its position in the list. | |
parent: Id; | |
fractionalIndex: FractionalIndex; | |
data: T; | |
}; | |
export type RelativePosition = { | |
parent: Id; | |
childBefore?: Id; | |
childAfter?: Id; | |
}; | |
const tabId = v4(); | |
const getTransact = <T>(crdt: YObject<T>): ((f: () => void) => void) => | |
crdt.doc?.transact ?? ((f) => f()); | |
const fromInternal = <T>(tree: YMoveableTreeInternal<T>): YMoveableTree<T> => | |
tree as any; | |
const toInternal = <T>(tree: YMoveableTree<T>): YMoveableTreeInternal<T> => | |
tree as any; | |
// eslint-disable-next-line @typescript-eslint/no-redeclare | |
export namespace YMoveableTree { | |
export const create = <T>(treeProp: Tree<T>): YMoveableTree<T> => { | |
const createRec = (tree: Tree<T>): MoveableTree<T> => { | |
const root = { id: tree.id, data: tree.value }; | |
const withIndices = zip( | |
tree.children, | |
generateNKeysBetween( | |
tree.children.length, | |
undefined, | |
undefined, | |
), | |
) as [Tree<T>, string][]; | |
const entries = withIndices.flatMap(([node, idx]) => { | |
const nodeEntry = [ | |
node.id, | |
{ | |
data: node.value, | |
fractionalIndex: [tabId, idx], | |
parent: root.id, | |
}, | |
] satisfies [Id, MoveableTreeNode<T>]; | |
const subtree = createRec(node); | |
const childrenEntries = Object.entries( | |
subtree.nodes, | |
) satisfies [Id, MoveableTreeNode<T>][]; | |
return [nodeEntry, ...childrenEntries]; | |
}); | |
const nodes = Object.fromEntries(entries); | |
return { | |
root, | |
nodes, | |
}; | |
}; | |
const rawMoveableTree = createRec(treeProp); | |
return fromInternal( | |
createYObject({ | |
root: createYObject(rawMoveableTree.root), | |
nodes: createYObject( | |
mapValues(rawMoveableTree.nodes, (node) => { | |
return createYObject(node); | |
}), | |
), | |
}), | |
); | |
}; | |
export const toTree = <T>(treeProp: YMoveableTree<T>): Tree<T> => { | |
const treeCRDT = toInternal(treeProp); | |
const nodes = treeCRDT.get('nodes'); | |
const root = treeCRDT.get('root'); | |
const allNodeEntries = Array.from(nodes.entries()).map( | |
([key, value]) => [key, unwrapYObject(value)] as const, | |
); | |
const grouped = groupBy( | |
allNodeEntries, | |
([, node]) => node.parent ?? 'null', | |
); | |
const entriesByParent = mapValues(grouped, (entries) => | |
entries.sort((a, b) => | |
fractionalIndexAsc(a[1].fractionalIndex, b[1].fractionalIndex), | |
), | |
); | |
const buildTrees = (parentId: Id): Tree<T>[] => { | |
return (entriesByParent[parentId] ?? []).map( | |
([id, node]): Tree<T> => ({ | |
value: node.data, | |
id, | |
children: buildTrees(id), | |
}), | |
); | |
}; | |
return { | |
value: root.get('data'), | |
id: root.get('id'), | |
children: buildTrees(root.get('id')), | |
}; | |
}; | |
export const moveBetween = <T>( | |
nodeIdsToMove: Id[], | |
position: RelativePosition, | |
treeProp: YMoveableTree<T>, | |
): void => { | |
const treeCRDT = toInternal(treeProp); | |
const nodes = treeCRDT.get('nodes'); | |
const transact = getTransact(treeCRDT); | |
const nodeBefore = position.childBefore | |
? nodes.get(position.childBefore) | |
: undefined; | |
const nodeAfter = position.childAfter | |
? nodes.get(position.childAfter) | |
: undefined; | |
const nodesToMove = nodeIdsToMove | |
.map((id) => nodes.get(id)) | |
.filter((x) => !!x); | |
const keys = generateNKeysBetween( | |
nodesToMove.length, | |
nodeBefore?.get('fractionalIndex')[1], | |
nodeAfter?.get('fractionalIndex')[1], | |
); | |
const nodesAndKeys = zip(nodesToMove, keys); | |
transact(() => { | |
for (const [node, key] of nodesAndKeys) { | |
if (!node || !key) { | |
continue; | |
} | |
node.set('parent', position.parent); | |
node.set('fractionalIndex', [tabId, key]); | |
} | |
}); | |
}; | |
export const addNodeBetween = <T>( | |
node: { id: Id; data: T }, | |
position: RelativePosition, | |
treeProp: YMoveableTree<T>, | |
): void => { | |
const treeCRDT = toInternal(treeProp); | |
const nodes = treeCRDT.get('nodes'); | |
const nodeBefore = position.childBefore | |
? nodes.get(position.childBefore) | |
: undefined; | |
const nodeAfter = position.childAfter | |
? nodes.get(position.childAfter) | |
: undefined; | |
nodes.set( | |
node.id, | |
createYObject({ | |
data: node.data, | |
parent: position.parent, | |
fractionalIndex: [ | |
tabId, | |
generateKeyBetween( | |
nodeBefore?.get('fractionalIndex')[1], | |
nodeAfter?.get('fractionalIndex')[1], | |
), | |
], | |
}), | |
); | |
}; | |
export const removeNode = <T>( | |
nodeId: Id, | |
treeProp: YMoveableTree<T>, | |
): void => { | |
const treeCRDT = toInternal(treeProp); | |
const nodes = treeCRDT.get('nodes'); | |
nodes.delete(nodeId); | |
}; | |
export const getNode = <T>( | |
nodeId: Id, | |
treeProp: YMoveableTree<T>, | |
): T | undefined => { | |
const treeCRDT = toInternal(treeProp); | |
return treeCRDT.get('nodes').get(nodeId)?.get('data'); | |
}; | |
export const getAllNodes = <T>( | |
treeProp: YMoveableTree<T>, | |
): Record<Id, T> => { | |
const treeCRDT = toInternal(treeProp); | |
return Object.fromEntries( | |
Array.from(treeCRDT.get('nodes').entries()).map(([key, value]) => [ | |
key, | |
value.get('data'), | |
]), | |
); | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const scale = ( | |
domain: [number, number], | |
range: [number, number], | |
value: number, | |
) => | |
range[0] + | |
((value - domain[0]) / (domain[1] - domain[0])) * (range[1] - range[0]); | |
export const DIGITS = | |
"_-,;:!?.'()[]{}@*/&#%`^+<=>|~$0123456789aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ"; | |
const DIGIT_TO_INDEX = Object.fromEntries( | |
DIGITS.split('').map((c, i) => [c, i]), | |
); | |
const SMALLEST_DIGIT = DIGITS.at(0)!; | |
const LARGEST_DIGIT = DIGITS.at(-1)!; | |
const deserializeKey = (key: string) => | |
key.split('').map((c) => DIGIT_TO_INDEX[c]); | |
const serializeKey = (key: number[]) => | |
key.map((index) => DIGITS[index]).join(''); | |
const getMidDigit = (prevD: number, nextD: number) => { | |
// We add some jitter to avoid concurrently creating | |
// indices that are too close to each other. | |
const betweenRatio = scale([0, 1], [0.25, 0.75], Math.random()); | |
return Math.floor(prevD + (nextD - prevD) * betweenRatio); | |
}; | |
export const generateKeyBetween = ( | |
prevKey: string | undefined = SMALLEST_DIGIT, | |
nextKey: string | undefined = LARGEST_DIGIT, | |
): string => { | |
const prevDigits = deserializeKey(prevKey); | |
const nextDigits = deserializeKey(nextKey); | |
const betweenDigits: number[] = []; | |
const maxLength = Math.max(prevDigits.length, nextDigits.length); | |
for (let i = 0; i < maxLength; i++) { | |
let prevD = prevDigits[i] ?? 0; | |
let nextD = nextDigits[i] ?? DIGITS.length - 1; | |
if (prevD === nextD) { | |
betweenDigits.push(prevD); | |
continue; | |
} | |
let middleD = getMidDigit(prevD, nextD); | |
betweenDigits.push(middleD); | |
while (middleD === prevD) { | |
i++; | |
prevD = prevDigits[i] ?? 0; | |
nextD = nextDigits[i] ?? DIGITS.length - 1; | |
middleD = getMidDigit(prevD, nextD); | |
betweenDigits.push(middleD); | |
} | |
} | |
return serializeKey(betweenDigits); | |
}; | |
export const generateNKeysBetween = ( | |
n: number, | |
prevKey: string | undefined = SMALLEST_DIGIT, | |
nextKey: string | undefined = LARGEST_DIGIT, | |
): string[] => { | |
if (n === 0) { | |
return []; | |
} | |
if (n === 1) { | |
return [generateKeyBetween(prevKey, nextKey)]; | |
} | |
if (nextKey == null) { | |
let newKey = generateKeyBetween(prevKey, nextKey); | |
const result = [newKey]; | |
for (let i = 0; i < n - 1; i++) { | |
newKey = generateKeyBetween(newKey, nextKey); | |
result.push(newKey); | |
} | |
return result; | |
} | |
if (prevKey == null) { | |
let newKey = generateKeyBetween(prevKey, nextKey); | |
const result = [newKey]; | |
for (let i = 0; i < n - 1; i++) { | |
newKey = generateKeyBetween(prevKey, newKey); | |
result.push(newKey); | |
} | |
result.reverse(); | |
return result; | |
} | |
const mid = Math.floor(n / 2); | |
const newKey = generateKeyBetween(prevKey, nextKey); | |
return [ | |
...generateNKeysBetween(mid, prevKey, newKey), | |
newKey, | |
...generateNKeysBetween(n - mid - 1, newKey, nextKey), | |
]; | |
}; | |
/** | |
* `FractionalIndex` is a tuple because we have | |
* to resolve potential conflicts where several users would | |
* generate the same key. | |
*/ | |
export type FractionalIndex = [replicaId: string, key: string]; | |
export const fractionalIndexAsc = (a: FractionalIndex, b: FractionalIndex) => { | |
const comp = a[1].localeCompare(b[1]); | |
return comp === 0 ? a[0].localeCompare(b[0]) : comp; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Better Y.Map type for objects with a static set of keys. | |
*/ | |
interface YObjectConstructor { | |
new <T extends {}>(entries?: Iterable<Entries<T>>): YObject<T>; | |
} | |
export const YObject = Y.Map as unknown as YObjectConstructor; | |
// eslint-disable-next-line @typescript-eslint/no-redeclare | |
export interface YObject<T> | |
extends Omit< | |
Y.Map<ValueOf<T>>, | |
| 'new' | |
| 'set' | |
| 'get' | |
| 'delete' | |
| 'has' | |
| 'forEach' | |
| 'toJSON' | |
| 'entries' | |
> { | |
set: YObjectSet<T>; | |
get: YObjectGet<T>; | |
delete: YObjectDelete<T>; | |
has: YObjectHas<T>; | |
forEach: YObjectForEach<T>; | |
toJSON(): object; | |
entries(): Iterable<Entries<Extract<T, {}>>>; | |
} | |
type YObjectSet<T> = <K extends keyof T>(key: K, value: T[K]) => void; | |
type YObjectGet<T> = <K extends keyof T>(key: K) => T[K]; | |
type YObjectDelete<T> = (key: OptionalKeysOf<T>) => void; | |
type YObjectHas<T> = (key: keyof T) => boolean; | |
type YObjectForEach<T> = <K extends keyof T>( | |
f: (value: T[K], key: K) => void, | |
) => void; | |
export const createYObject = <T extends object>(obj: T): YObject<T> => { | |
const yObj = new YObject<T>(); | |
for (const [key, value] of Object.entries(obj)) { | |
yObj.set(key as keyof T, value); | |
} | |
return yObj; | |
}; | |
export const unwrapYObject = <T extends object>(obj: YObject<T>): T => { | |
return Object.fromEntries(obj.entries()); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment