Data Updates#

This guide covers how to change a live Project as the user works: adding, updating and removing tasks, links, resources and assignments, rescheduling after the edit, and recovering when part of an edit is refused.

How editing works#

A Project holds one accepted state: the data from the last solve(data, …) plus every edit since, with the dates the last solve accepted. You don't send the whole plan again for each change. You describe the change as a SchedulingChanges object and pass it to Project#apply.

Editing takes two separate calls:

  1. apply(changes) writes the edit into the accepted state and marks the project unsolved. It doesn't schedule anything.
  2. solve(null, options) schedules the edited state, accepts the new dates and returns what moved.

Between the two calls the project has no baseline. preview, loadProfile, evaluate, suggestAssignments and roster throw ProjectException with code NO_BASELINE until you solve. You can call apply several times before one solve, and the edits accumulate.

apply accepts what it can. Each operation in the change set applies or is rejected on its own. A rejection isn't an exception. It's listed in the returned ApplyReport and sets needsResync. From then on the project refuses solve(null, …), apply and level with NEEDS_RESYNC. Your model and the project's state may now disagree, and the only way back is to send the full data with solve(data, …).

When apply itself throws a ProjectException, the call didn't fit the project's state: NEEDS_RESYNC after an earlier rejection, or STALE_BASELINE when the change set names a baseline that isn't current anymore. In both cases nothing was applied. Both exceptions are unchecked. See Error handling.

Change sets#

A SchedulingChanges groups the edits by record kind. Each group is a Changes<TAdd, TUpdate> with three lists: add, update and remove. All of them start empty, so you add only what the edit needs.

Group add update remove
tasks TaskData TaskUpdate task Id
links LinkAdd LinkUpdate link Id
resources ResourceData ResourceUpdate resource Id
assignments AssignmentData AssignmentUpdate assignment Id

Additions use the same record types as ProjectData, except links: a LinkAdd may leave id as null. Leave a link or assignment id null and apply generates one ($generated_link_1, $generated_assignment_1, ...). Generated ids come back in ApplyReport.effects, so you can store them in your model.

import java.util.List;
import org.dhtmlx.plen.*;

public class ChangeSet {
  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    ProjectData data = new ProjectData()
        .withTasks(List.of(
            new TaskData(Id.num(1)).withStartDate(monday).withDuration(2.0),
            new TaskData(Id.num(2)).withStartDate(monday).withDuration(3.0)))
        .withLinks(List.of(
            new LinkData().withId(Id.num(1)).withSource(Id.num(1)).withTarget(Id.num(2))));

    Project project = new Project();
    SolveOptions options = new SolveOptions();
    project.solve(data, options);

    SchedulingChanges changes = new SchedulingChanges();
    // A new task, linked after task 2. The link has no id: apply generates one.
    changes.tasks.add.add(new TaskData(Id.num(3)).withStartDate(monday).withDuration(1.0));
    changes.links.add.add(new LinkAdd().withSource(Id.num(2)).withTarget(Id.num(3)));
    // Task 1 now takes three days.
    changes.tasks.update.add(new TaskUpdate(Id.num(1)).withDuration(Patch.of(3.0)));

    ApplyReport report = project.apply(changes);
    for (AppliedOperation op : report.applied) {
      System.out.println("applied " + op.path);
    }
    System.out.println("generated links: " + report.effects.generatedLinks);

    ScheduleResult result = project.solve(null, options);
    for (Mutation update : result.updates) {
      System.out.println("task " + update.id + ": " + update.startDate + " .. " + update.endDate);
    }
  }
}

The report lists tasks.add[0], tasks.update[0] and links.add[0], and the new link is $generated_link_1. After the solve, task 1 runs until 2026-08-13 08:00, task 2 moves to 2026-08-13 08:00 .. 2026-08-18 08:00, and task 3 starts when task 2 ends.

The lists are ordinary Vec (ArrayList) fields, so changes.tasks.update.add(...) appends one operation. To set a whole list at once, use the withAdd, withUpdate and withRemove setters, which also take List.of(...).

Operations don't run in the order you added them. apply runs them in a fixed order, so every reference comes after the adds it may need, and every removal comes before the edits it could break:

  1. tasks: remove, add, update
  2. links: remove, update, add
  3. resources: remove, add, update
  4. assignments: remove, update, add

An operation is named by its group, list and index, such as tasks.update[0]. The same path shows up in ApplyReport.applied and ApplyReport.rejected.

Update records and Patch#

Update records (TaskUpdate, LinkUpdate, ResourceUpdate, AssignmentUpdate) name the record by id and carry every other field as a Patch<T>. A patch has three states:

  • Patch.missing() (the default) leaves the field as it is.
  • Patch.of(value) sets it.
  • Patch.nullValue() clears an optional field, or restores the default of a field that always has a value: autoScheduling goes back to true, unscheduled to false, progress to 0, priority to 500, levelingDelay to 0, pauses to empty, linkType to FS and lag to 0.

new TaskUpdate(id) and the other update constructors set every field to Patch.missing(), so you only touch the fields you mean to change. Each field has a fluent withX(patch) setter.

Patch.of(null) isn't the same as Patch.nullValue(). It's a value patch that holds null. On a task or resource calendarId it clears the calendar. On fields that can't hold null (task parent and pauses, link source and target, assignment task and resource, the boxed numbers and flags) it throws NullPointerException when applied. Use Patch.nullValue() to clear.

import java.util.List;
import org.dhtmlx.plen.*;

public class PinUnpin {
  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    Moment thursday = Moment.fromYmdHm(2026, 8, 13, 8, 0);
    Id review = Id.str("review");

    ProjectData data = new ProjectData()
        .withConfig(new SchedulingConfig().withApplyConstraints(true))
        .withTasks(List.of(new TaskData(review).withStartDate(monday).withDuration(1.0)));
    Project project = new Project();
    SolveOptions options = new SolveOptions();
    project.solve(data, options);

    // Patch.of sets a field: "start no earlier than Thursday".
    SchedulingChanges pin = new SchedulingChanges();
    pin.tasks.update.add(new TaskUpdate(review)
        .withConstraintType(Patch.of(ConstraintType.SNET))
        .withConstraintDate(Patch.of(thursday)));
    project.apply(pin);
    for (Mutation update : project.solve(null, options).updates) {
      System.out.println("pinned: " + update.id + " starts " + update.startDate);
    }

    // Patch.nullValue() clears the constraint; startDate moves the task back.
    // Every field left Patch.missing() keeps its value.
    SchedulingChanges unpin = new SchedulingChanges();
    unpin.tasks.update.add(new TaskUpdate(review)
        .withConstraintType(Patch.nullValue())
        .withConstraintDate(Patch.nullValue())
        .withStartDate(Patch.of(monday)));
    project.apply(unpin);
    for (Mutation update : project.solve(null, options).updates) {
      System.out.println("unpinned: " + update.id + " starts " + update.startDate);
    }
  }
}

The first solve moves the task to 2026-08-13 08:00. The second puts it back on Monday. Clearing the constraint alone wouldn't do that: the accepted state already holds the Thursday dates, so the update also sets startDate.

Which dates a task update keeps#

A task has three linked date fields: startDate, endDate and duration. When an update sets some of them, apply keeps what you sent and derives the rest:

  • startDate alone keeps the duration and recomputes the end (a drag).
  • endDate alone keeps the start and recomputes the duration (a resize from the right edge).
  • duration alone keeps the start and recomputes the end.
  • startDate and endDate together recompute the duration.
  • A new calendarId, or a new parent while calendars are inherited (CalendarMode.TASK_INHERITED), keeps the start and duration and recomputes the end on the new calendar. Under an inherited calendar, the descendants that inherit it re-derive their ends too.

When a task has no predecessor, an explicit startDate on non-working time is kept as sent. The end is computed forward from it on the working calendar, so only the start sits outside working time. Snap the date in your UI if you don't want that.

Feeding results back#

A Mutation from a result uses the same field names as TaskUpdate, and sets only the fields that changed (the others are null). A result entry maps field by field onto an update. Use this to commit dates you got from preview or from a leveled copy:

static <T> Patch<T> patch(T value) {
  return value == null ? Patch.missing() : Patch.of(value);
}

static TaskUpdate toUpdate(Mutation mutation) {
  return new TaskUpdate(mutation.id)
      .withStartDate(patch(mutation.startDate))
      .withEndDate(patch(mutation.endDate))
      .withDuration(patch(mutation.duration))
      .withLevelingDelay(patch(mutation.levelingDelay))
      .withPauses(patch(mutation.pauses));
}

pauses replaces the whole list. Set it to Patch.of(new Vec<>()) or Patch.nullValue() to remove every pause. See Clear leveling.

Applying#

apply returns an ApplyReport:

  • applied lists the path of every accepted operation.
  • rejected lists the path and message of every refused one.
  • effects is a ChangeEffects with what the edit changed beyond the records you named (below).
  • needsResync is true when anything was rejected.
  • revision is the project's revision number after this edit, as a long.

Removals cascade, and effects tells you what went with them. Removing a task removes its descendants, every link that touches any of them and every assignment on them. Removing a resource removes its assignments.

import java.util.List;
import org.dhtmlx.plen.*;

public class Cascade {
  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    ProjectData data = new ProjectData()
        .withTasks(List.of(
            new TaskData(Id.str("phase")).withTaskType(TaskType.SUMMARY),
            new TaskData(Id.str("spec")).withParent(Id.str("phase"))
                .withStartDate(monday).withDuration(2.0),
            new TaskData(Id.str("release")).withStartDate(monday).withDuration(1.0)))
        .withLinks(List.of(new LinkData()
            .withId(Id.str("spec-release"))
            .withSource(Id.str("spec"))
            .withTarget(Id.str("release"))))
        .withResources(List.of(new ResourceData().withId(Id.str("ann"))))
        .withAssignments(List.of(new AssignmentData()
            .withId(Id.str("ann-spec"))
            .withTask(Id.str("spec"))
            .withResource(Id.str("ann"))
            .withValue(1.0)));
    Project project = new Project();
    project.solve(data, new SolveOptions());

    SchedulingChanges changes = new SchedulingChanges();
    changes.tasks.remove.add(Id.str("phase"));
    ChangeEffects effects = project.apply(changes).effects;

    System.out.println("tasks: " + effects.removedTasks);
    System.out.println("links: " + effects.removedLinks);
    System.out.println("assignments: " + effects.removedAssignments);
  }
}

Removing the summary phase also removes spec, the link spec-release and the assignment ann-spec. Delete the same records from your own model.

The other effects lists:

  • generatedLinks and generatedAssignments hold the ids apply made up for additions without one.
  • droppedLinks and droppedAssignments hold records that were accepted but are now inactive, for example a link to an unscheduled task. They stay in the project and come back into play when the task does.

apply edits the project in place. Copies made earlier with Project#copy() don't see the edit, so you can copy a project, apply a multi-step edit to the copy and drop it if the user cancels. A copy is cheap: both sides share state until one of them is edited. For a single edit, preview does the same job without a copy. See Previewing changes.

Field-only task edits and assignment updates take a fast path that patches the affected records without rebuilding the whole model. Frequent small edits, like drags, stay cheap.

Rescheduling#

After apply, call solve(null, options). It schedules the edited state, accepts the dates as the new baseline and returns the updates relative to the previous one.

solve always reports the tasks that a successful operation added or updated, even when the planner leaves them exactly where the edit put them. The same goes for tasks whose working calendars changed because of a resource or assignment edit. Your model gets the derived end or duration for every task you edited, not only for the ones that moved. These reported tasks accumulate across several apply calls until the next acyclic solve(null, …) or level. A cyclic solve keeps them pending.

Guarding against stale edits#

A UI usually edits the dates it showed the user. If the project changed in between (an edit from another tab, a leveling run), applying the edit can do the wrong thing. Set SchedulingChanges.baselineId to the token of the result the user saw. apply then refuses the whole change set with STALE_BASELINE when that token isn't current.

import java.util.List;
import org.dhtmlx.plen.*;

public class Stale {
  public static void main(String[] args) throws Exception {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    ProjectData data = new ProjectData()
        .withTasks(List.of(new TaskData(Id.num(1)).withStartDate(monday).withDuration(2.0)));

    Project project = new Project();
    SolveOptions options = new SolveOptions();
    String seen = project.solve(data, options).baselineId;

    // The edit was made against the dates the user saw.
    SchedulingChanges changes = new SchedulingChanges().withBaselineId(seen);
    changes.tasks.update.add(new TaskUpdate(Id.num(1)).withDuration(Patch.of(3.0)));
    project.apply(changes);

    // A second edit still carrying the old token is refused as a whole.
    try {
      project.apply(changes);
    } catch (ProjectException e) {
      if (e.code == ProjectError.STALE_BASELINE) {
        System.out.println("stale: reload first");
      } else {
        throw e;
      }
    }

    String fresh = project.solve(null, options).baselineId;
    System.out.println("new baseline differs: " + !fresh.equals(seen));
  }
}

A token names a baseline only while the project is solved and clean. After an apply, any baselineId is stale until the next solve, so take a fresh token from each result. Tokens are opaque and process-unique: don't store them or compare them across runs. null (the default) skips the check.

Rejections and resync#

An operation is rejected when it can't apply:

  • an unknown id in update or remove
  • a duplicate id in add
  • Patch.nullValue() on a required field (a link's source or target, an assignment's task, resource or value)
  • a record that fails validation after the edit, such as an invalid pause or assignment slice

apply tries each operation on its own copy. A rejected operation leaves no partial edit behind, and the operations around it still apply.

import java.util.List;
import org.dhtmlx.plen.*;

public class Resync {
  static ProjectData load() {
    Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    return new ProjectData()
        .withTasks(List.of(new TaskData(Id.str("design")).withStartDate(monday).withDuration(2.0)));
  }

  public static void main(String[] args) throws Exception {
    Project project = new Project();
    SolveOptions options = new SolveOptions();
    project.solve(load(), options);

    SchedulingChanges changes = new SchedulingChanges();
    changes.tasks.remove.add(Id.str("deleted-elsewhere"));
    changes.tasks.update.add(new TaskUpdate(Id.str("design")).withDuration(Patch.of(4.0)));

    ApplyReport report = project.apply(changes);
    for (AppliedOperation op : report.applied) {
      System.out.println("applied " + op.path);
    }
    for (RejectedOperation op : report.rejected) {
      System.out.println("rejected " + op.path + ": " + op.message);
    }

    if (report.needsResync) {
      // solve(null, ...) and apply now throw NEEDS_RESYNC until the full data is sent again.
      try {
        project.solve(null, options);
      } catch (ProjectException e) {
        if (e.code != ProjectError.NEEDS_RESYNC) throw e;
        project.solve(load(), options);
      }
    }
  }
}

The update applies and the removal is rejected with rejected tasks.remove[0]: unknown task id. The project now holds the update but not the removal, which is probably not what your model holds. That's why any rejection blocks further edits: solve(null, …), apply and level throw ProjectException with code NEEDS_RESYNC until a successful solve(data, …) replaces the whole state with data you know is right.

The resync data must pass validation. If it doesn't, you get an InputException, and the project still needs a resync. See Recover from rejected changes.

evaluate and suggestAssignments use the same apply on disposable copies. A rejected candidate shows up as its CandidateResult.error (a RejectedOperation) and doesn't block the project. See Evaluating alternatives.

What change sets can't do#

Some edits have no operation. For these, rebuild the data in your model and send it with solve(data, …):

  • Calendars can't be added, changed or removed. Tasks and resources can switch to another existing calendar through calendarId.
  • SchedulingConfig can't change. The duration unit, useCalendars, calendarMode, applyConstraints and the other settings are fixed until the next full solve.
  • A task's taskType and durationUnit aren't in TaskUpdate. To turn a task into a summary or a milestone, remove it and add it again (its links and assignments go with the removal), or resync.
  • An id can't be renamed. update finds the record by its id and never changes it.
  • ResourceUpdate changes only capacity and calendarId.

Calls that don't need a change set don't take one. level writes its own levelingDelay and pauses edits and accepts the result. See Leveling resources.