[Canonical page](<https://docs.dhtmlx.com/planning-engine/wasm/live-projects/editing-a-project/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Editing a Project

This guide covers how to change a live [`JsonProject`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md>) as the user works. It explains how to add, update and remove tasks, links, resources and assignments, how to reschedule after an edit, and how to recover when part of an edit is refused.

## How editing works

A [`JsonProject`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md>) holds one accepted state: the data from the last [`solve(dataJson, …)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>), every edit since, and the dates the last solve accepted. You don't send the whole plan again for each change. Describe the change as a [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md>) object, serialize it, and pass it to [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>).

Editing takes two separate calls:

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

Between the two calls the project has no baseline. [`preview`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-preview.md>), [`loadProfile`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-load-profile.md>), [`evaluate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-evaluate.md>), [`slots`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-slots.md>) and [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md>) return a `project` error with code [`no-baseline`](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md#project-errors>) until you solve. You can call `apply` several times before one `solve`, and the edits add up.

`apply` accepts what it can. Each operation in the change set is applied or rejected on its own. A rejection isn't an error envelope. The response is `ok: true`, the rejection is listed in the returned [`ApplyReport`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md#applyreport>), and `needsResync` is set. From then on, `solve` without data, `apply` and [`level`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-level.md>) return a `project` error with code [`needs-resync`](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md#recovering-from-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(dataJson, …)`.

An error envelope from `apply` means nothing was applied. The cases are:

- `project` with code `stale-baseline`: the change set names a baseline that isn't current anymore. This check runs first.
- `project` with code `needs-resync`: an earlier `apply` rejected an operation.
- `parse` or `validation`: the JSON itself was refused. Examples are an unknown field in the change set or its buckets, a missing required field, or a value the decoder can't read, such as a malformed date.

See [Error handling](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md>) for the envelope and [Wire format](<https://docs.dhtmlx.com/planning-engine/wasm/wire-format.md>) for field spelling.

## Change sets

A [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md>) object groups the edits by record kind. Each group has up to three lists: `add`, `update` and `remove`. Leave out any group or list you don't need.

| Group | `add` | `update` | `remove` |
|---|---|---|---|
| `tasks` | [`TaskData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/task-data.md>) | [`TaskUpdate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/task-update.md>) | task ids |
| `links` | [`LinkAdd`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md#linkadd>) | [`LinkUpdate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md#linkupdate>) | link ids |
| `resources` | [`ResourceData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md#resourcedata>) | [`ResourceUpdate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md#resourceupdate>) | resource ids |
| `assignments` | [`AssignmentData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/assignment-data.md>) | [`AssignmentUpdate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md#assignmentupdate>) | assignment ids |

Additions use the same records as [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/project-data.md>), in the same snake_case spelling, except links: in `links.add` the `id` is optional. Leave out a link or assignment `id` (or send `null`), and `apply` generates one (`$generated_link_1`, `$generated_assignment_1`, ...). Generated ids come back in the report's [`effects`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md#changeeffects>), so you can store them in your model.

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 3 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  const changes = {
    tasks: {
      // A new task, and task 1 now takes three days.
      add: [{ id: 3, start_date: "2026-08-10 08:00", duration: 1 }],
      update: [{ id: 1, duration: 3 }],
    },
    // The new link has no id: apply generates one.
    links: { add: [{ source: 2, target: 3, type: "fs" }] },
  };
  const report = unwrap(project.apply(JSON.stringify(changes)));
  for (const op of report.applied) console.log(`applied ${op.path}`);
  console.log("generated links:", report.effects.generatedLinks);

  const result = unwrap(project.solve(undefined, "{}"));
  for (const update of result.updates) {
    console.log(`task ${update.id}: ${update.start_date} .. ${update.end_date}`);
  }
} finally {
  project.free();
}
```

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: 2026-08-10 08:00 .. 2026-08-13 08:00
task 2: 2026-08-13 08:00 .. 2026-08-18 08:00
task 3: 2026-08-18 08:00 .. 2026-08-19 08:00
```

`apply` doesn't run the operations in the order you wrote them. It uses a [fixed order](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md#operation-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 the [report](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md#applyreport>)'s `applied` and `rejected` lists.

The change set and its `{add, update, remove}` buckets reject unknown fields with a `parse` error. The records inside the lists ignore them, like records in `ProjectData`.

## Update records

An update record names its target by `id` and lists only the fields to change. Each field has three states:

- Left out: the field keeps its value.
- A value: the field is set.
- `null`: an optional field is cleared, or a field that always has a value goes back to its default. `auto_scheduling` goes back to `true`, `unscheduled` to `false`, `progress` to `0`, `priority` to `500`, `leveling_delay` to `0`, `pauses` to empty, a link's `type` to `"fs"` and its `lag` to `0`.

`constraint_type: ""` clears the constraint the same way `null` does.

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

function edit(project, update) {
  const report = unwrap(project.apply(JSON.stringify({ tasks: { update: [update] } })));
  if (report.needsResync) throw new Error(report.rejected[0].message);
  return unwrap(project.solve(undefined, "{}")).updates;
}

const data = {
  config: { apply_constraints: true },
  tasks: [{ id: "review", start_date: "2026-08-10 08:00", duration: 1 }],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  // A value sets a field: "start no earlier than Thursday".
  for (const update of edit(project, {
    id: "review",
    constraint_type: "snet",
    constraint_date: "2026-08-13 08:00",
  })) {
    console.log(`pinned: ${update.id} starts ${update.start_date}`);
  }

  // null clears the constraint; start_date moves the task back.
  // Every field you leave out keeps its value.
  for (const update of edit(project, {
    id: "review",
    constraint_type: null,
    constraint_date: null,
    start_date: "2026-08-10 08:00",
  })) {
    console.log(`unpinned: ${update.id} starts ${update.start_date}`);
  }
} finally {
  project.free();
}
```

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 `start_date`.

Be careful with `undefined` and non-finite numbers in your update objects. `JSON.stringify` drops a field set to `undefined`, so the field stays unchanged. It turns `NaN` and `Infinity` into `null`, which clears the field. Check values before you serialize them.

The full field lists are on [`TaskUpdate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/task-update.md>) and [common types](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md>).

:::note
A `priority` outside 0..1000 or a malformed hour range fails the whole call with a `validation` error that has no `path`. In `apply`, this means no operation from that change set is applied.
:::

### Which dates a task update keeps

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

- `start_date` alone keeps the duration and recomputes the end (a drag).
- `end_date` alone keeps the start and recomputes the duration (a resize from the right edge).
- `duration` alone keeps the start and recomputes the end.
- `start_date` and `end_date` together recompute the duration.
- A new `calendar_id`, or a new `parent` while [`config.calendar_mode`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md#calendar-mode>) is `"task-inherited"`, keeps the start and duration and recomputes the end on the new calendar. Descendants that inherit that calendar re-derive their ends too.

An explicit `start_date` on non-working time is kept as sent when the task has no predecessor. 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

The entries in `result.updates` (see [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#mutation>)) use the same snake_case field names as a task update. Their extra `kind` and `reason` fields are ignored as unknown fields. So you can send result entries back to [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>) as they are. This is how you commit dates from [`preview`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-preview.md>) or from a leveled [clone](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#clone>):

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 2 },
  ],
  links: [{ id: "l", source: 1, target: 2, type: "fs" }],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  const preview = unwrap(
    project.preview(JSON.stringify({ tasks: { update: [{ id: 1, duration: 3 }] } }), "{}"),
  );

  // Commit exactly the dates the user saw.
  const report = unwrap(
    project.apply(
      JSON.stringify({ baselineId: preview.baselineId, tasks: { update: preview.updates } }),
    ),
  );
  if (report.needsResync) throw new Error("edit rejected; resync with solve(dataJson, ...)");
  unwrap(project.solve(undefined, "{}"));
} finally {
  project.free();
}
```

[`pauses`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md#pause>) replaces the whole list. Send `[]` or `null` to remove every pause. See [Clear leveling](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/clear-leveling.md>).

## Applying

[`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>) returns an [`ApplyReport`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md#applyreport>) in the envelope's `result`:

- `applied` lists the path of every accepted operation.
- `rejected` lists the path and message of every refused one.
- [`effects`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md#changeeffects>) lists 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.

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.

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: "phase", type: "summary" },
    { id: "spec", parent: "phase", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "release", start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: "spec-release", source: "spec", target: "release", type: "fs" }],
  resources: [{ id: "ann" }],
  assignments: [{ id: "ann-spec", task: "spec", resource: "ann", value: 1 }],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  const report = unwrap(project.apply(JSON.stringify({ tasks: { remove: ["phase"] } })));
  const { removedTasks, removedLinks, removedAssignments } = report.effects;
  console.log("tasks:", removedTasks);
  console.log("links:", removedLinks);
  console.log("assignments:", removedAssignments);
} finally {
  project.free();
}
```

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 inactive, for example a link to an unscheduled task. They stay in the project and come back into play when the task does. The next solve explains each one in a `dropped-link` or `dropped-assignment` [note](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#note>).

`apply` edits the handle in place. A [`clone()`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#clone>) made earlier doesn't see the edit. To try a multi-step edit, apply it to a clone and [`free()`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#free>) the clone if the user cancels. For a single edit, [`preview`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-preview.md>) does the same job without a clone. See [Previewing changes](<https://docs.dhtmlx.com/planning-engine/wasm/live-projects/previewing-changes.md>).

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. The change set is still JSON text, so send only the fields that changed. See [Module and handles](<https://docs.dhtmlx.com/planning-engine/wasm/module-and-handles.md>).

## Rescheduling

After `apply`, call [`solve(undefined, optionsJson)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>). It schedules the edited state, accepts the dates as the new baseline and returns the updates relative to the previous one.

Tasks that a successful operation added or updated are always reported, 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. So your model gets the derived end or duration of every task you edited, not only of the ones that moved. These reported tasks accumulate across several `apply` calls until the next acyclic `solve` without data, or [`level`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-level.md>). 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 [`baselineId`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md>) in the change set to the token of the result the user saw. `apply` then refuses the whole change set with [`stale-baseline`](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md#project-errors>) when that token isn't current.

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = { tasks: [{ id: 1, start_date: "2026-08-10 08:00", duration: 2 }] };

const project = new JsonProject("{}");
try {
  const loaded = unwrap(project.solve(JSON.stringify(data), "{}"));
  const seen = loaded.baselineId;

  // The edit was made against the dates the user saw.
  const changesJson = JSON.stringify({
    baselineId: seen,
    tasks: { update: [{ id: 1, duration: 3 }] },
  });
  unwrap(project.apply(changesJson));

  // A second edit still carrying the old token is refused as a whole.
  const response = JSON.parse(project.apply(changesJson));
  if (!response.ok && response.error.code === "stale-baseline") {
    console.log("stale: reload first");
  }

  const solved = unwrap(project.solve(undefined, "{}"));
  console.log("new baseline differs:", solved.baselineId !== seen);
} finally {
  project.free();
}
```

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: don't store them or compare them across runs. Leave `baselineId` out to skip the check.

## Rejections and resync

An operation is rejected when it can't apply. Typical causes:

- an unknown id in `update` or `remove`
- a duplicate id in `add`
- `null` 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

Each operation is tried on its own copy, so a rejected operation leaves no partial edit behind, and the operations around it still apply.

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// Your own model: the source of truth for a resync.
function loadJson() {
  return JSON.stringify({
    tasks: [{ id: "design", start_date: "2026-08-10 08:00", duration: 2 }],
  });
}

const project = new JsonProject("{}");
try {
  unwrap(project.solve(loadJson(), "{}"));

  const changes = {
    tasks: {
      remove: ["deleted-elsewhere"],
      update: [{ id: "design", duration: 4 }],
    },
  };
  const report = unwrap(project.apply(JSON.stringify(changes)));
  for (const op of report.applied) console.log(`applied ${op.path}`);
  for (const op of report.rejected) console.log(`rejected ${op.path}: ${op.message}`);

  if (report.needsResync) {
    // solve without data and apply now fail until the full data is sent again.
    const response = JSON.parse(project.solve(undefined, "{}"));
    if (!response.ok && response.error.code === "needs-resync") {
      unwrap(project.solve(loadJson(), "{}"));
    }
  }
} finally {
  project.free();
}
```

The update applies and the removal is rejected:

```
applied tasks.update[0]
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` without data, `apply` and `level` return [`needs-resync`](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md#recovering-from-needs-resync>) until a successful `solve(dataJson, …)` replaces the whole state with data you know is right.

The resync data must pass validation. If it doesn't, you get a `parse` or `validation` error envelope, and the project still needs a resync. See [Recover from rejected changes](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/recover-from-rejected-changes.md>).

[`evaluate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-evaluate.md>) and [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md>) run the same apply on disposable copies. A rejected candidate shows up as its [`CandidateResult.error`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-evaluate.md#candidateresult>) (`{path, message}`) instead of blocking the project. See [Evaluating alternatives](<https://docs.dhtmlx.com/planning-engine/wasm/resources-and-assignment/evaluating-alternatives.md>).

## What change sets can't do

Some edits have no operation. For these, rebuild the data in your model and send it with [`solve(dataJson, …)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>):

- Calendars can't be added, changed or removed. Tasks and resources can switch to another existing calendar through `calendar_id`.
- [`config`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-config.md>) can't change: `duration_unit`, `use_calendars`, `calendar_mode`, `apply_constraints` and the other settings stay fixed until the next full solve.
- A task's `type` and `duration_unit` aren't in [`TaskUpdate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/task-update.md>). 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.
- A [resource update](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md#resourceupdate>) changes only `capacity` and `calendar_id`.

Calls that don't need a change set don't take one. [`level`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-level.md>) writes its own `leveling_delay` and `pauses` edits and accepts the result. See [Leveling resources](<https://docs.dhtmlx.com/planning-engine/wasm/resources-and-assignment/leveling-resources.md>).
