hierarchical-approval 2.9.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  _Nothing yet._
9
9
 
10
+ ## [3.0.0] - 2026-09-04
11
+
12
+ Three defects found by auditing the interactions between features added across
13
+ 2.x, rather than by adding anything new.
14
+
15
+ ### Fixed — `updateData()` built condition-added levels wrong
16
+
17
+ - **A level a condition added during `updateData()` silently lost its `group`,
18
+ `subWorkflow`, `escalationAfterHours` and reminder configuration.** Levels
19
+ were constructed in two places — `submit()` and `recomputeFutureChain()` —
20
+ and the second copy had never been updated as fields were added across 1.0.0
21
+ to 2.6.0. The consequences were quiet and serious:
22
+
23
+ - a condition-added **parallel group ran sequentially**, one branch at a time,
24
+ because the levels came back without their `group`;
25
+ - a condition-added **sub-workflow level lost its binding**, then failed with
26
+ "No approvers resolved for this level" when reached — leaving an approval
27
+ that could never advance;
28
+ - hour-based escalation and reminders simply never fired.
29
+
30
+ A level whose configuration was unchanged was carried over intact, so this
31
+ only bit templates whose conditions *add* levels — and it bit them silently.
32
+
33
+ Both paths now go through one `buildLevelInstance()`, so a field cannot be
34
+ added to a level in one place and forgotten in the other. This is the same
35
+ failure the 1.6.0 Postgres column list had, in a different file.
36
+
37
+ ### BREAKING — a finished parent no longer leaves its sub-workflow children running
38
+
39
+ - **Cancelling or rejecting a parent left its child approval pending forever.**
40
+ The child kept notifying, kept escalating, and kept appearing in
41
+ `getWorkload()` — asking people to decide something whose outcome nobody would
42
+ ever read, since `propagateToParent()` ignores a parent that is no longer
43
+ pending. Children of a terminal parent are now cancelled, with the reason
44
+ naming the parent, and the child's own audit trail is left intact rather than
45
+ deleted.
46
+
47
+ **Behaviour change:** a child that used to stay open now reaches `cancelled`.
48
+ Anything counting open approvals, or waiting on a child whose parent has
49
+ ended, will see different numbers — correct ones.
50
+
51
+ - **`purgeInstances()` orphaned sub-workflow children.** It removed the parent
52
+ and left the child behind, holding a `parentInstanceId` pointing at a row that
53
+ no longer existed — unreachable, and invisible to a purge scoped by document
54
+ type, since a child usually has a different one. A purge now takes the whole
55
+ sub-workflow family together, parents first, deduplicated so a parent and
56
+ child sharing a terminal status are each reported once.
57
+
10
58
  ## [2.9.0] - 2026-09-04
11
59
 
12
60
  ### Added — `simulate()`
package/README.md CHANGED
@@ -689,6 +689,13 @@ Collapsing the non-approved outcomes into one rejection is deliberate: a parent
689
689
  that treated a cancelled child as "carry on" would advance past a gate nobody
690
690
  cleared.
691
691
 
692
+ **A finished parent ends its children.** If a parent is cancelled or rejected
693
+ while a child is still running, the child is cancelled too, with the reason
694
+ naming the parent — otherwise it would keep asking people to decide something
695
+ whose outcome nobody will read. A child that already finished is left alone.
696
+ `purgeInstances()` likewise removes a whole sub-workflow family together, so a
697
+ child is never orphaned behind a deleted parent.
698
+
692
699
  Children link back via `parentInstanceId` and `parentLevel`, and the level
693
700
  records `childInstanceId`. Nesting is allowed up to five levels deep, and
694
701
  `validateTemplate()` rejects a template that would spawn itself. Emits
@@ -995,6 +995,19 @@ declare class ApprovalEngine {
995
995
  * `businessHoursCalendar` to have hours skip evenings and weekends.
996
996
  */
997
997
  private deadlineFromHours;
998
+ /**
999
+ * Build a level instance from its template config.
1000
+ *
1001
+ * The single place a level is constructed. It previously happened twice — in
1002
+ * `submit()` and again in `recomputeFutureChain()` — and the second copy was
1003
+ * missing `group`, `subWorkflowTemplate`, `escalationAfterHours` and the
1004
+ * reminder fields, so a level added by a condition during `updateData()` came
1005
+ * out silently different from the same level created at submit.
1006
+ *
1007
+ * @param cfg - The template's configuration for this level.
1008
+ * @param opts - `open` activates the level now, computing deadlines from `now`.
1009
+ */
1010
+ private buildLevelInstance;
998
1011
  /** Level deadline from whichever of days/hours the template configured. */
999
1012
  private levelEscalationDue;
1000
1013
  /** First rung of a ladder, sorted by delay, or undefined when there is none. */
@@ -1098,6 +1111,27 @@ declare class ApprovalEngine {
1098
1111
  * about them, so a slow child template would surface as a spurious conflict
1099
1112
  * on the decision the user just made.
1100
1113
  */
1114
+ /**
1115
+ * An instance and every sub-workflow descendant beneath it, parents first.
1116
+ *
1117
+ * A child is only reachable through its parent's `childInstanceId`, so a
1118
+ * purge that removed the parent alone would strand the rest of the tree.
1119
+ * Depth is bounded by the same cap that limits spawning, and an already-seen
1120
+ * id is skipped so a corrupted link cannot loop.
1121
+ */
1122
+ private collectSubWorkflowFamily;
1123
+ /**
1124
+ * Cancel sub-workflow children whose parent has finished.
1125
+ *
1126
+ * A child outlives its parent otherwise: it stays pending, keeps notifying
1127
+ * and escalating, and keeps appearing in {@link getWorkload} — asking people
1128
+ * to decide something whose outcome nobody will ever read, because
1129
+ * {@link propagateToParent} ignores a parent that is no longer pending.
1130
+ *
1131
+ * Cancelling rather than deleting keeps the child's own audit trail intact:
1132
+ * the people who were asked, and why the request stopped, stay on the record.
1133
+ */
1134
+ private cancelOrphanedChildren;
1101
1135
  private afterDecision;
1102
1136
  private findNextLevel;
1103
1137
  private findPreviousLevel;
@@ -995,6 +995,19 @@ declare class ApprovalEngine {
995
995
  * `businessHoursCalendar` to have hours skip evenings and weekends.
996
996
  */
997
997
  private deadlineFromHours;
998
+ /**
999
+ * Build a level instance from its template config.
1000
+ *
1001
+ * The single place a level is constructed. It previously happened twice — in
1002
+ * `submit()` and again in `recomputeFutureChain()` — and the second copy was
1003
+ * missing `group`, `subWorkflowTemplate`, `escalationAfterHours` and the
1004
+ * reminder fields, so a level added by a condition during `updateData()` came
1005
+ * out silently different from the same level created at submit.
1006
+ *
1007
+ * @param cfg - The template's configuration for this level.
1008
+ * @param opts - `open` activates the level now, computing deadlines from `now`.
1009
+ */
1010
+ private buildLevelInstance;
998
1011
  /** Level deadline from whichever of days/hours the template configured. */
999
1012
  private levelEscalationDue;
1000
1013
  /** First rung of a ladder, sorted by delay, or undefined when there is none. */
@@ -1098,6 +1111,27 @@ declare class ApprovalEngine {
1098
1111
  * about them, so a slow child template would surface as a spurious conflict
1099
1112
  * on the decision the user just made.
1100
1113
  */
1114
+ /**
1115
+ * An instance and every sub-workflow descendant beneath it, parents first.
1116
+ *
1117
+ * A child is only reachable through its parent's `childInstanceId`, so a
1118
+ * purge that removed the parent alone would strand the rest of the tree.
1119
+ * Depth is bounded by the same cap that limits spawning, and an already-seen
1120
+ * id is skipped so a corrupted link cannot loop.
1121
+ */
1122
+ private collectSubWorkflowFamily;
1123
+ /**
1124
+ * Cancel sub-workflow children whose parent has finished.
1125
+ *
1126
+ * A child outlives its parent otherwise: it stays pending, keeps notifying
1127
+ * and escalating, and keeps appearing in {@link getWorkload} — asking people
1128
+ * to decide something whose outcome nobody will ever read, because
1129
+ * {@link propagateToParent} ignores a parent that is no longer pending.
1130
+ *
1131
+ * Cancelling rather than deleting keeps the child's own audit trail intact:
1132
+ * the people who were asked, and why the request stopped, stay on the record.
1133
+ */
1134
+ private cancelOrphanedChildren;
1101
1135
  private afterDecision;
1102
1136
  private findNextLevel;
1103
1137
  private findPreviousLevel;
package/dist/index.cjs CHANGED
@@ -1475,33 +1475,14 @@ var ApprovalEngine = class _ApprovalEngine {
1475
1475
  const instanceId = this.generateId("inst");
1476
1476
  const firstCfg = allLevelCfgs[0];
1477
1477
  const firstGroupKey = firstCfg ? _ApprovalEngine.groupKeyOf(firstCfg) : null;
1478
- const levels = allLevelCfgs.map((cfg) => {
1479
- const inFirstGroup = _ApprovalEngine.groupKeyOf(cfg) === firstGroupKey;
1480
- return {
1481
- level: cfg.level,
1482
- name: cfg.name,
1483
- group: cfg.group,
1484
- mode: cfg.mode,
1485
- approverConfigs: cfg.approvers,
1486
- approverIds: [],
1487
- approvedBy: [],
1488
- rejectedBy: [],
1489
- status: inFirstGroup ? "pending" : "waiting",
1490
- minApprovals: cfg.minApprovals,
1491
- threshold: cfg.threshold,
1492
- weights: cfg.weights,
1493
- escalationAfterDays: cfg.escalationAfterDays,
1494
- escalationAfterHours: cfg.escalationAfterHours,
1495
- escalationStep: 0,
1496
- escalationDueAt: inFirstGroup ? this.levelEscalationDue(now, cfg, this.firstRungOf(template.escalationSteps)) : void 0,
1497
- subWorkflowTemplate: cfg.subWorkflow?.templateName,
1498
- reminderAfterDays: cfg.reminderAfterDays,
1499
- reminderEveryDays: cfg.reminderEveryDays,
1500
- maxReminders: cfg.maxReminders,
1501
- remindersSent: 0,
1502
- reminderDueAt: inFirstGroup && cfg.reminderAfterDays ? this.deadlineFrom(now, cfg.reminderAfterDays) : void 0
1503
- };
1504
- });
1478
+ const firstRung = this.firstRungOf(template.escalationSteps);
1479
+ const levels = allLevelCfgs.map(
1480
+ (cfg) => this.buildLevelInstance(cfg, {
1481
+ open: _ApprovalEngine.groupKeyOf(cfg) === firstGroupKey,
1482
+ now,
1483
+ firstRung
1484
+ })
1485
+ );
1505
1486
  for (const lvl of levels.filter((l) => l.status === "pending")) {
1506
1487
  if (lvl.subWorkflowTemplate) {
1507
1488
  lvl.approverIds = [];
@@ -2176,7 +2157,10 @@ var ApprovalEngine = class _ApprovalEngine {
2176
2157
  cancelled = instance;
2177
2158
  return instance;
2178
2159
  });
2179
- if (cancelled) await this.propagateToParent(cancelled);
2160
+ if (cancelled) {
2161
+ await this.cancelOrphanedChildren(cancelled);
2162
+ await this.propagateToParent(cancelled);
2163
+ }
2180
2164
  return result;
2181
2165
  }
2182
2166
  async escalate(instanceId, raw, auditCtx) {
@@ -2329,20 +2313,7 @@ var ApprovalEngine = class _ApprovalEngine {
2329
2313
  const rebuiltFuture = futureCfgs.map((cfg) => {
2330
2314
  const kept = existingFuture.get(cfg.level);
2331
2315
  if (kept) return kept;
2332
- return {
2333
- level: cfg.level,
2334
- name: cfg.name,
2335
- mode: cfg.mode,
2336
- approverConfigs: cfg.approvers,
2337
- approverIds: [],
2338
- approvedBy: [],
2339
- rejectedBy: [],
2340
- status: "waiting",
2341
- minApprovals: cfg.minApprovals,
2342
- threshold: cfg.threshold,
2343
- weights: cfg.weights,
2344
- escalationAfterDays: cfg.escalationAfterDays
2345
- };
2316
+ return this.buildLevelInstance(cfg, { open: false, now: this.clock.now() });
2346
2317
  });
2347
2318
  instance.levels = [...frozen, ...rebuiltFuture];
2348
2319
  return { addedLevels: addedLevels.sort((a, b) => a - b), removedLevels };
@@ -3659,6 +3630,7 @@ var ApprovalEngine = class _ApprovalEngine {
3659
3630
  );
3660
3631
  }
3661
3632
  const result = { purged: [], scanned: 0, dryRun };
3633
+ const handled = /* @__PURE__ */ new Set();
3662
3634
  for (const status of requested) {
3663
3635
  if (result.purged.length >= limit) break;
3664
3636
  const page = await this.opts.adapter.getInstancesByFilter(
@@ -3672,15 +3644,21 @@ var ApprovalEngine = class _ApprovalEngine {
3672
3644
  );
3673
3645
  for (const instance of page.items) {
3674
3646
  if (result.purged.length >= limit) break;
3647
+ if (handled.has(instance.id)) continue;
3675
3648
  result.scanned++;
3676
3649
  if (!TERMINAL_STATUSES.has(instance.status)) continue;
3677
3650
  if (new Date(instance.createdAt) > opts.olderThan) continue;
3678
- if (!dryRun) await deleteInstance(this.tenantId, instance.id);
3679
- result.purged.push({
3680
- instanceId: instance.id,
3681
- documentId: instance.documentId,
3682
- status: instance.status
3683
- });
3651
+ const family = await this.collectSubWorkflowFamily(instance);
3652
+ for (const member of family) {
3653
+ if (handled.has(member.id)) continue;
3654
+ handled.add(member.id);
3655
+ if (!dryRun) await deleteInstance(this.tenantId, member.id);
3656
+ result.purged.push({
3657
+ instanceId: member.id,
3658
+ documentId: member.documentId,
3659
+ status: member.status
3660
+ });
3661
+ }
3684
3662
  }
3685
3663
  }
3686
3664
  this.logger.info("purgeInstances: sweep complete", {
@@ -4056,6 +4034,47 @@ var ApprovalEngine = class _ApprovalEngine {
4056
4034
  const addHours = this.calendar?.addBusinessHours?.bind(this.calendar);
4057
4035
  return addHours ? addHours(from, hours) : new Date(from.getTime() + hours * 36e5);
4058
4036
  }
4037
+ /**
4038
+ * Build a level instance from its template config.
4039
+ *
4040
+ * The single place a level is constructed. It previously happened twice — in
4041
+ * `submit()` and again in `recomputeFutureChain()` — and the second copy was
4042
+ * missing `group`, `subWorkflowTemplate`, `escalationAfterHours` and the
4043
+ * reminder fields, so a level added by a condition during `updateData()` came
4044
+ * out silently different from the same level created at submit.
4045
+ *
4046
+ * @param cfg - The template's configuration for this level.
4047
+ * @param opts - `open` activates the level now, computing deadlines from `now`.
4048
+ */
4049
+ buildLevelInstance(cfg, opts) {
4050
+ const level = {
4051
+ level: cfg.level,
4052
+ name: cfg.name,
4053
+ group: cfg.group,
4054
+ mode: cfg.mode,
4055
+ approverConfigs: cfg.approvers,
4056
+ approverIds: [],
4057
+ approvedBy: [],
4058
+ rejectedBy: [],
4059
+ status: opts.open ? "pending" : "waiting",
4060
+ minApprovals: cfg.minApprovals,
4061
+ threshold: cfg.threshold,
4062
+ weights: cfg.weights,
4063
+ subWorkflowTemplate: cfg.subWorkflow?.templateName,
4064
+ escalationAfterDays: cfg.escalationAfterDays,
4065
+ escalationAfterHours: cfg.escalationAfterHours,
4066
+ escalationStep: 0,
4067
+ reminderAfterDays: cfg.reminderAfterDays,
4068
+ reminderEveryDays: cfg.reminderEveryDays,
4069
+ maxReminders: cfg.maxReminders,
4070
+ remindersSent: 0
4071
+ };
4072
+ if (opts.open) {
4073
+ level.escalationDueAt = this.levelEscalationDue(opts.now, cfg, opts.firstRung);
4074
+ this.scheduleReminder(level, opts.now);
4075
+ }
4076
+ return level;
4077
+ }
4059
4078
  /** Level deadline from whichever of days/hours the template configured. */
4060
4079
  levelEscalationDue(from, level, firstRung) {
4061
4080
  if (level.escalationAfterHours) return this.deadlineFromHours(from, level.escalationAfterHours);
@@ -4507,8 +4526,68 @@ var ApprovalEngine = class _ApprovalEngine {
4507
4526
  * about them, so a slow child template would surface as a spurious conflict
4508
4527
  * on the decision the user just made.
4509
4528
  */
4529
+ /**
4530
+ * An instance and every sub-workflow descendant beneath it, parents first.
4531
+ *
4532
+ * A child is only reachable through its parent's `childInstanceId`, so a
4533
+ * purge that removed the parent alone would strand the rest of the tree.
4534
+ * Depth is bounded by the same cap that limits spawning, and an already-seen
4535
+ * id is skipped so a corrupted link cannot loop.
4536
+ */
4537
+ async collectSubWorkflowFamily(root) {
4538
+ const family = [root];
4539
+ const seen = /* @__PURE__ */ new Set([root.id]);
4540
+ const queue = [root];
4541
+ for (let depth = 0; queue.length > 0 && depth <= MAX_SUBWORKFLOW_DEPTH; depth++) {
4542
+ const generation = queue.splice(0, queue.length);
4543
+ for (const parent of generation) {
4544
+ for (const level of parent.levels) {
4545
+ const childId = level.childInstanceId;
4546
+ if (!childId || seen.has(childId)) continue;
4547
+ seen.add(childId);
4548
+ const child = await this.opts.adapter.getInstance(this.tenantId, childId);
4549
+ if (!child) continue;
4550
+ family.push(child);
4551
+ queue.push(child);
4552
+ }
4553
+ }
4554
+ }
4555
+ return family;
4556
+ }
4557
+ /**
4558
+ * Cancel sub-workflow children whose parent has finished.
4559
+ *
4560
+ * A child outlives its parent otherwise: it stays pending, keeps notifying
4561
+ * and escalating, and keeps appearing in {@link getWorkload} — asking people
4562
+ * to decide something whose outcome nobody will ever read, because
4563
+ * {@link propagateToParent} ignores a parent that is no longer pending.
4564
+ *
4565
+ * Cancelling rather than deleting keeps the child's own audit trail intact:
4566
+ * the people who were asked, and why the request stopped, stay on the record.
4567
+ */
4568
+ async cancelOrphanedChildren(parent) {
4569
+ const childIds = parent.levels.map((l) => l.childInstanceId).filter((id) => typeof id === "string");
4570
+ if (childIds.length === 0) return;
4571
+ for (const childId of childIds) {
4572
+ try {
4573
+ const child = await this.opts.adapter.getInstance(this.tenantId, childId);
4574
+ if (!child || TERMINAL_STATUSES.has(child.status)) continue;
4575
+ await this.cancel(childId, {
4576
+ cancelledBy: "system",
4577
+ reason: `Parent approval ${parent.id} ended as "${parent.status}".`
4578
+ });
4579
+ } catch (err) {
4580
+ this.logger.error("subWorkflow: failed to cancel orphaned child", err, {
4581
+ tenantId: this.tenantId,
4582
+ parentInstanceId: parent.id,
4583
+ childInstanceId: childId
4584
+ });
4585
+ }
4586
+ }
4587
+ }
4510
4588
  async afterDecision(instance) {
4511
4589
  if (TERMINAL_STATUSES.has(instance.status)) {
4590
+ await this.cancelOrphanedChildren(instance);
4512
4591
  await this.propagateToParent(instance);
4513
4592
  return;
4514
4593
  }