Skip to content
This repository was archived by the owner on Feb 4, 2022. It is now read-only.

Commit 83197ae

Browse files
committed
feat: add support for custom conflict handlers
Custom conflict handlers allow consumers to provide additional domain-specific conflict handling in addition to what's handled by default.
1 parent a1618df commit 83197ae

File tree

3 files changed

+98
-7
lines changed

3 files changed

+98
-7
lines changed

src/State.ts

+31-1
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,35 @@ import { OpMove } from "./OpMove";
1919
import { Tree } from "./Tree";
2020
import { TreeNode } from "./TreeNode";
2121

22+
interface StateOptions<Id, Metadata> {
23+
/**
24+
* An function to provide domain-specific conflict handling logic.
25+
* The resulting boolean value determines whether the operation conflicts.
26+
*
27+
* This is useful if metadata collision can produce conflicts in your business
28+
* logic. For example, making name collisions impossible in a filesystem.
29+
*/
30+
conflictHandler?: (
31+
operation: OpMove<Id, Metadata>,
32+
tree: Tree<Id, Metadata>
33+
) => boolean;
34+
}
35+
2236
export class State<Id, Metadata> {
2337
/** A list of `LogOpMove` in descending timestamp order */
2438
readonly operationLog: LogOpMove<Id, Metadata>[] = [];
2539
/** A tree structure that represents the current state of the tree */
2640
tree: Tree<Id, Metadata> = new Tree();
41+
/** Returns true if the given operation should be discarded */
42+
conflictHandler: (
43+
operation: OpMove<Id, Metadata>,
44+
tree: Tree<Id, Metadata>
45+
) => boolean;
46+
47+
constructor(options: StateOptions<Id, Metadata> = {}) {
48+
// Default to not handling conflict
49+
this.conflictHandler = options.conflictHandler ?? (() => false);
50+
}
2751

2852
/** Insert a log entry to the top of the log */
2953
addLogEntry(entry: LogOpMove<Id, Metadata>) {
@@ -80,7 +104,7 @@ export class State<Id, Metadata> {
80104
// `oldNode` records the previous parent and metadata of c.
81105
const oldNode = this.tree.get(op.id);
82106

83-
// ensures no cycles are introduced. If the node c
107+
// ensures no cycles are introduced. If the node c
84108
// is being moved, and c is an ancestor of the new parent
85109
// newp, then the tree is returned unmodified, ie the operation
86110
// is ignored.
@@ -89,6 +113,12 @@ export class State<Id, Metadata> {
89113
return { op, oldNode };
90114
}
91115

116+
// ignores operations that produce conflicts according to the
117+
// custom conflict handler.
118+
if (this.conflictHandler(op, this.tree)) {
119+
return { op, oldNode };
120+
}
121+
92122
// Otherwise, the tree is updated by removing c from
93123
// its existing parent, if any, and adding the new
94124
// parent-child relationship (newp, m, c) to the tree.

src/TreeReplica.ts

+20-3
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,37 @@
1313
import { Clock } from "./Clock";
1414
import { OpMove } from "./OpMove";
1515
import { State } from "./State";
16+
import { Tree } from "./Tree";
1617
import { TreeNode } from "./TreeNode";
1718

19+
interface ReplicaOptions<Id, Metadata> {
20+
/**
21+
* An function to provide domain-specific conflict handling logic.
22+
* The resulting boolean value determines whether the operation conflicts.
23+
*
24+
* This is useful if metadata collision can produce conflicts in your business
25+
* logic. For example, making name collisions impossible in a filesystem.
26+
*/
27+
conflictHandler?: (
28+
operation: OpMove<Id, Metadata>,
29+
tree: Tree<Id, Metadata>
30+
) => boolean;
31+
}
32+
1833
export class TreeReplica<Id, Metadata> {
1934
/** The Tree state */
20-
state: State<Id, Metadata> = new State();
35+
state: State<Id, Metadata>;
2136
/** The logical clock for this replica/tree */
2237
time: Clock<Id>;
2338
/** Mapping of replicas and their latest time */
2439
latestTimeByReplica: Map<Id, Clock<Id>> = new Map();
2540
/** A tree structure that represents the current state of the tree */
26-
tree = this.state.tree;
41+
tree: Tree<Id, Metadata>;
2742

28-
constructor(authorId: Id) {
43+
constructor(authorId: Id, options: ReplicaOptions<Id, Metadata> = {}) {
2944
this.time = new Clock(authorId);
45+
this.state = new State(options);
46+
this.tree = this.state.tree;
3047
}
3148

3249
/** Get a node by its id */

test/tree.test.ts

+47-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { TreeReplica } from "../src";
1+
import { OpMove, Tree, TreeReplica } from "../src";
22

33
let id = 1;
44
const newId = () => String(++id);
@@ -37,7 +37,7 @@ test("concurrent moves converge to a common location", () => {
3737

3838
// The state is the same on both replicas, converging to /root/c/a
3939
// because last-write-wins and replica2's op has a later timestamp
40-
expect(r1.state).toEqual(r2.state);
40+
expect(r1.state.toString()).toEqual(r2.state.toString());
4141
expect(r1.state.tree.nodes.get(ids.a)?.parentId).toBe(ids.c);
4242
});
4343

@@ -75,7 +75,51 @@ test("concurrent moves avoid cycles, converging to a common location", () => {
7575

7676
// The state is the same on both replicas, converging to /root/a/b
7777
// because last-write-wins and replica2's op has a later timestamp
78-
expect(r1.state).toEqual(r2.state);
78+
expect(r1.state.toString()).toEqual(r2.state.toString());
7979
expect(r1.state.tree.nodes.get(ids.b)?.parentId).toBe(ids.a);
8080
expect(r1.state.tree.nodes.get(ids.a)?.parentId).toBe(ids.root);
8181
});
82+
83+
test("custom conflict handler supports metadata-based custom conflicts", () => {
84+
type Id = string;
85+
type FileName = string;
86+
87+
// A custom handler that rejects if a sibling exists with the same name
88+
function conflictHandler(op: OpMove<Id, FileName>, tree: Tree<Id, FileName>) {
89+
const siblings = tree.children.get(op.parentId) ?? [];
90+
return [...siblings].some(id => {
91+
const isSibling = id !== op.id;
92+
const hasSameName = tree.get(id)?.metadata === op.metadata;
93+
return isSibling && hasSameName;
94+
});
95+
}
96+
97+
const r1 = new TreeReplica<Id, FileName>("a", { conflictHandler });
98+
const r2 = new TreeReplica<Id, FileName>("b", { conflictHandler });
99+
100+
const ids = {
101+
root: newId(),
102+
a: newId(),
103+
b: newId()
104+
};
105+
106+
const ops = r1.opMoves([
107+
[ids.root, "root", "0"],
108+
[ids.a, "a", ids.root],
109+
[ids.b, "b", ids.root]
110+
]);
111+
112+
r1.applyOps(ops);
113+
r2.applyOps(ops);
114+
115+
// Replica 1 renames /root/a to /root/b, producing a conflict
116+
let repl1Ops = [r1.opMove(ids.a, "b", ids.root)];
117+
118+
r1.applyOps(repl1Ops);
119+
r2.applyOps(repl1Ops);
120+
121+
// The state is the same on both replicas, ignoring the operation that
122+
// produced conflicting metadata state
123+
expect(r1.state.toString()).toEqual(r2.state.toString());
124+
expect(r1.state.tree.nodes.get(ids.a)?.metadata).toBe("a");
125+
});

0 commit comments

Comments
 (0)