Error handling#
This guide covers how Planning Engine reports failures in JavaScript: which problems throw, which come back as data inside a normal result, and how to keep a live project usable after each kind.
How failures surface#
Every Project method and schedule is synchronous. A call either returns its result or throws, so a plain try/catch around the call is all you need. There are no rejected promises to handle; the only async export, init(), never fails.
The library throws two error classes of its own. Both extend Error, and both are exported from the package:
| Class | name |
Meaning | Project state after the throw |
|---|---|---|---|
InputError |
"InputError" |
The data or options you passed are invalid. | Unchanged. The previous accepted state and its cached result are kept. |
ProjectError |
"ProjectError" |
The input may be fine, but the call doesn't fit the project's current state. | Unchanged. Call the right method first, then retry. |
Most of what goes wrong in a plan isn't an error at all. Dependency cycles, constraint conflicts, dropped links, a planner that didn't converge and rejected apply operations come back inside the result. A throw means the call itself couldn't run.
A few other throws exist: a disposed project, input that can't be converted (circular objects, BigInt), and an internal engine failure. Other throws covers them.
Catching errors#
Tell the two library errors apart with instanceof, then read kind and path on an InputError or code on a ProjectError. Rethrow anything else, so that bugs in your own code don't get reported as bad input:
// Prints "invalid input at tasks[0].duration: invalid duration"
;
Both classes are plain Error subclasses, so an error that passes through your own layers keeps its class and fields. To add context, wrap it with the standard cause option and keep the original for later checks:
const = new;
Input errors#
An InputError carries three fields besides name:
kindis"parse"or"validation"."parse"means the input doesn't have the expected shape: a wrong type (duration: "bad") or an unknown key in options or a change set."validation"means the shape is right but a value isn't allowed.messageis a short description, such asinvalid durationordated constraint requires constraint_date.pathpoints at the offending field, in the formtasks[0].duration,tasks[0].constraint_dateorpool.workers[1].resource. It'snullfor every"parse"error and for a few validation errors, such as apriorityoutside 0..1000.
With the default validation: "strict", a solve(data) or schedule call rejects the input on the first problem it finds. Strict validation rejects, among other things:
- a leaf task with fewer than two of
start_date,end_dateandduration, or with all three when they don't agree - a dated constraint (
"snet","fnlt", ...) without aconstraint_date - duplicate task, link or calendar ids (
1and"1"count as the same id) - an unknown
parent, or a cycle in theparenthierarchy - a summary with
start_date+duration(a summary takes both dates or neither) - a negative
duration, apriorityabove 1000, a negativeleveling_delay - an assignment to an unknown task or resource, or with
value: 0 "week","month"or"year"duration units, and calendars with no working time
The full list per field is on the reference pages for ProjectData, TaskData and AssignmentData.
Some values are rejected before the engine sees the data. While the library reads your objects, a number that isn't finite (NaN, Infinity) throws InputError("validation", "Numbers must be finite"), and an invalid Date throws InputError("validation", "Invalid Date"). For a non-finite number, path is only the property name (for example "duration"), not the full path to it. For an invalid Date, path is null.
Reporting a validation error with its path#
message is only the description. Read path yourself when you need to point the user at the field:
const = ;
A rejected replacement keeps the old state#
On a live Project, a solve(data) that fails validation installs nothing. The project keeps its accepted baseline, and solve(null) still returns the result you had before:
const = new;
const = ;
// Still the first data set.
const = ;
; // true
Invalid project options#
The constructor validates its options too. An unknown dates value fails with path: "dates"; an unknown validation mode or an unknown option key also throws:
new Project(options) throws an InputError if an option is unknown or has an invalid value.
Project errors#
A ProjectError tells you the call came at the wrong moment. Its code names the reason, and its message is the same string. Each code has one recovery:
code |
When it's thrown | Recovery |
|---|---|---|
"no-baseline" |
preview, loadProfile, evaluate, slots or suggestAssignments on a project with no solved baseline, including right after apply |
Call solve first. After apply, call solve(null) before any of these methods. |
"stale-baseline" |
A baselineId in the options or in SchedulingChanges doesn't name the current baseline |
The project was solved, edited or leveled since you read the token. Take baselineId from the latest result and retry. |
"needs-resync" |
solve(null), apply or level after an apply rejected an operation |
Send the full data again with solve(data). Nothing else is accepted until that call succeeds. |
Telling the codes apart#
// An unsolved project: prints "solve the project first".
;
preview checks both options.baselineId and changes.baselineId. Baseline tokens are opaque and can differ between runs for the same data, so never store them across sessions or compare them in tests.
Recovering from needs-resync#
A rejected operation in apply doesn't throw. It's listed in the report's rejected array, and the report's needsResync is true. From then on, solve(null), apply and level throw ProjectError("needs-resync"), because your copy of the plan and the project's copy no longer agree. Resync with the full data you hold:
const = ;
const = new;
;
// Task 999 doesn't exist: the operation is rejected.
const = ;
;
If the resync data itself fails validation, the call throws an InputError and the project still needs a resync. Fix the data and call solve(data) again. The recovery how-to covers this flow in an editor.
Results that are not errors#
These all come back in a normal result. Check them on every result you show to a user:
| Where | What it means |
|---|---|
ScheduleResult.cycles |
Dependency cycles. A cyclic solve returns unchanged dates and empty updates, and still installs a baseline. |
ScheduleResult.conflicts |
"constraint-violation" (a link and a hard constraint disagree, and the constraint wins) and "calendar-non-working" |
ScheduleResult.notes |
Input the engine dropped ("dropped-link", "dropped-task", "dropped-assignment") or fixed ("repaired") |
ScheduleResult.converged |
false when the planner didn't reach a fixed point within config.max_iterations. The dates are still valid output. |
ApplyReport.rejected |
Operations that apply refused. The other operations in the change set were still applied. |
CandidateResult.error |
An evaluate candidate whose change set was rejected. That candidate has no metrics; the other candidates are scored normally. |
SuggestResult.unfilled / invalid |
Targeted empty slots that suggestAssignments left empty, and slots still held by a resource outside their allowed list. The rest of the proposal is still usable. |
Checking a schedule for problems#
const = ;
Dependency cycles are accepted state. Cycles in the parent hierarchy are different: they're invalid input and throw an InputError at tasks[i].parent.
Repair instead of reject#
validation: "strict" suits data your own code produces. For data from a UI or an older store, new Project({ validation: "repair" }) coerces the input instead of throwing:
- duplicate ids: the last record wins, at the first record's position
- missing dates or durations are derived
- in an inconsistent start/end/duration triple the dates win
- dated constraints without a date are cleared
- invalid
pauseslists and assignment slice values are cleared
Each fix shows up as a "repaired" note with a path and a message, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems (a negative duration, for example) still throw an InputError.
const = new;
const = ;
The messy input how-to shows the repair flow end to end.
Errors from slots#
slots throws the same two classes as every other method:
InputErrorwithkind: "validation"for a bad pool or requirement. The path names the item:pool.workers[0].resource,requirements[2].count,requirements[1].assignments[0].ProjectError("no-baseline")when the project has no clean solved baseline. Callsolvebeforeslots, including afterapply.
So one catch covers the whole staffing flow, slots followed by suggestAssignments:
const = ;
const = ;
slots throws InputError / ProjectError like the other Project methods.
Other throws#
A few failures don't use the library's error classes (other errors):
| Thrown | When | What to do |
|---|---|---|
Error("Project has been disposed") |
Any method call on a project after dispose() |
Don't use a project after disposing it. A clone made earlier is unaffected. |
TypeError |
The input contains a circular reference or a BigInt, the same cases where JSON.stringify throws |
Pass plain data: convert BigInt ids to numbers or strings, and break cycles. |
Error with name === "Panic" |
An internal engine check failed | This is a bug in the library, not in your data. Report it with the input that caused it. |
const = new;
;
dispose() is optional; after it, every call on that project throws Error("Project has been disposed").
Check error.name === "Panic" to tell an engine failure apart from your own bugs. It's an ordinary Error, not an exported class, so instanceof won't work for it.