Recover from rejected changes#

Get an editor working again after apply rejects part of a change set, without restarting the app or losing the user's edits.

When apply rejects an operation, the report has needsResync: true. From then on, solve(null), apply and level throw ProjectError with code "needs-resync". Calls that need a baseline (preview, loadProfile, evaluate, suggestAssignments) throw ProjectError with code "no-baseline". To clear the flag, send the full data again with solve(data). Editing a project explains why.

Prerequisites#

  • A long-lived Project that you solved once with solve(data).
  • Your own model that can produce the full ProjectData the user expects to see. You resync from this data.

Steps#

  1. Apply the change set and keep the report:

    const report = project.apply(changes);
    

    If apply throws, nothing was applied. ProjectError means "needs-resync" from an earlier rejection or "stale-baseline". InputError means a malformed change set. A rejection inside the batch doesn't throw.

  2. Check report.needsResync. If it's false, continue as usual with solve(null):

    if (!report.needsResync) {
      project.solve(null);
    }
    
  3. Show the user what was refused. Each entry of report.rejected has the operation's path (such as tasks.remove[0]) and a message:

    for (const op of report.rejected) {
      console.error(`rejected ${op.path}: ${op.message}`);
    }
    

    The other operations in the batch were applied. A rejected operation leaves no partial edit behind.

  4. Make your model hold the state you want. Undo the rejected edits in it, or keep it as it is if it's already correct. Don't try to repair the project with more apply calls, because they throw "needs-resync".

  5. Resync with the full data. Set includeUnchanged so the result has the dates of every task, not only the ones that moved:

    const result = project.solve(model, { includeUnchanged: true });
    
  6. Write every entry of result.updates back into your UI. The engine's state can differ from what the UI shows, so refresh all task dates, not only the edited ones.

  7. Use the new result.baselineId for the next preview or change set. Tokens from before the resync are stale.

The full example below stretches one task. In the same batch, it removes a task the engine doesn't have:

import { InputError, Project } from "plen-js";

// The editor's own model: the data you resync from.
const model = {
  tasks: [{ id: "design", start_date: "2026-01-05", duration: 2 }],
  // 24/7 time keeps the example dates easy to check.
  config: { use_calendars: false },
};

const project = new Project();
project.solve(model);

// The user stretches "design" to 4 days. The UI also sends a removal
// of "review", a task the engine never had.
model.tasks[0].duration = 4;
const report = project.apply({
  tasks: {
    update: [{ id: "design", duration: 4 }],
    remove: ["review"],
  },
});

if (!report.needsResync) {
  project.solve(null);
} else {
  for (const op of report.rejected) {
    console.error(`rejected ${op.path}: ${op.message}`);
  }

  try {
    const result = project.solve(model, { includeUnchanged: true });
    for (const update of result.updates) {
      console.log(update.id, `${update.start_date}..${update.end_date}`);
    }
  } catch (error) {
    if (!(error instanceof InputError)) throw error;
    // The project keeps its state and still needs a resync.
    console.error(`resync data invalid at ${error.path}: ${error.message}`);
  }
}

When the resync data is invalid#

The resync goes through the same validation as the first load. If your model produces invalid data, such as a duplicate task id, solve(data) throws InputError with the field path, for example tasks[1].id. The project keeps its previous state, and solve(null), apply and level still throw "needs-resync". Fix the data and call solve(data) again. See Error handling for the full list of validation errors.

Drop the edit instead of resyncing#

To avoid a full reload, apply the change set to a clone() first. A rejection on the clone doesn't touch the original:

const draft = project.clone();
const report = draft.apply(changes);
if (report.needsResync) {
  // Drop `draft`. `project` still has its baseline; roll the edit back in your model too.
} else {
  draft.solve(null);
  project = draft;
}

Use this when the user should get either the whole change set or none of it.

Note: clone() copies the whole project at once, so its cost grows with the project's size.

Result#

The example prints the rejected removal to stderr and the refreshed dates of every task to stdout:

rejected tasks.remove[0]: unknown task id
design 2026-01-05 00:00..2026-01-09 00:00

After the resync, apply, solve(null), level and preview work again, and report.needsResync is false for the next valid change set.