hierarchical-approval 2.0.0 → 2.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +34 -0
  3. package/dist/{ApprovalEngine-B8bPZq01.d.ts → ApprovalEngine-CJwnpnpL.d.ts} +52 -7
  4. package/dist/{ApprovalEngine-C7p9cOA3.d.cts → ApprovalEngine-DqRIadYg.d.cts} +52 -7
  5. package/dist/{IAuditAdapter-UJNdJanq.d.cts → IAuditAdapter-BHVKMMv6.d.cts} +1 -1
  6. package/dist/{IAuditAdapter-DDV4Rf9F.d.ts → IAuditAdapter-C4OASS6u.d.ts} +1 -1
  7. package/dist/{IAuthorizationPolicy-CFJ-gXKl.d.ts → IAuthorizationPolicy-BA_m2Dg9.d.ts} +1 -1
  8. package/dist/{IAuthorizationPolicy-CN6LAaKg.d.cts → IAuthorizationPolicy-CtLkwQ8I.d.cts} +1 -1
  9. package/dist/{INotificationAdapter-Dv_MuYwS.d.ts → INotificationAdapter-BdgfUrIn.d.ts} +12 -2
  10. package/dist/{INotificationAdapter-Dy2d0JKy.d.cts → INotificationAdapter-JJAiMyMd.d.cts} +12 -2
  11. package/dist/{IOperationMiddleware-DWYXVuJD.d.ts → IOperationMiddleware-BGYrAqep.d.ts} +1 -1
  12. package/dist/{IOperationMiddleware-DGG-guxK.d.cts → IOperationMiddleware-D0ksiFM3.d.cts} +1 -1
  13. package/dist/{IStorageAdapter-Bgd6p8XL.d.cts → IStorageAdapter-Bk7ybd3z.d.cts} +1 -1
  14. package/dist/{IStorageAdapter-B8aRYeGI.d.ts → IStorageAdapter-DjRvHUF0.d.ts} +1 -1
  15. package/dist/adapters/MemoryAdapter.d.cts +2 -2
  16. package/dist/adapters/MemoryAdapter.d.ts +2 -2
  17. package/dist/adapters/PostgresAdapter.d.cts +2 -2
  18. package/dist/adapters/PostgresAdapter.d.ts +2 -2
  19. package/dist/index.cjs +271 -13
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +8 -8
  22. package/dist/index.d.ts +8 -8
  23. package/dist/index.js +271 -13
  24. package/dist/index.js.map +1 -1
  25. package/dist/{instance-BE0uJmg3.d.cts → instance-DUJY_Axf.d.cts} +41 -3
  26. package/dist/{instance-BE0uJmg3.d.ts → instance-DUJY_Axf.d.ts} +41 -3
  27. package/dist/nestjs.cjs +271 -13
  28. package/dist/nestjs.cjs.map +1 -1
  29. package/dist/nestjs.d.cts +7 -7
  30. package/dist/nestjs.d.ts +7 -7
  31. package/dist/nestjs.js +271 -13
  32. package/dist/nestjs.js.map +1 -1
  33. package/dist/plugins/audit.d.cts +2 -2
  34. package/dist/plugins/audit.d.ts +2 -2
  35. package/dist/plugins/notify.d.cts +2 -2
  36. package/dist/plugins/notify.d.ts +2 -2
  37. package/dist/plugins/resilience.d.cts +3 -3
  38. package/dist/plugins/resilience.d.ts +3 -3
  39. package/dist/plugins/tracing.d.cts +2 -2
  40. package/dist/plugins/tracing.d.ts +2 -2
  41. package/dist/plugins/webhook.d.cts +2 -2
  42. package/dist/plugins/webhook.d.ts +2 -2
  43. package/dist/testing.cjs +271 -13
  44. package/dist/testing.cjs.map +1 -1
  45. package/dist/testing.d.cts +7 -7
  46. package/dist/testing.d.ts +7 -7
  47. package/dist/testing.js +271 -13
  48. package/dist/testing.js.map +1 -1
  49. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -914,6 +914,7 @@ function computeTimingStats(samples) {
914
914
  }
915
915
 
916
916
  // src/engine/ApprovalEngine.ts
917
+ var MAX_SUBWORKFLOW_DEPTH = 5;
917
918
  var DEFAULT_MAX_REMINDERS = 3;
918
919
  var DEFAULT_MAX_ATTEMPTS = 3;
919
920
  var DEFAULT_BASE_DELAY_MS = 50;
@@ -1018,12 +1019,32 @@ var ApprovalEngine = class _ApprovalEngine {
1018
1019
  });
1019
1020
  }
1020
1021
  levelNums.add(l.level);
1021
- if (!l.approvers || l.approvers.length === 0) {
1022
+ if (!l.subWorkflow && (!l.approvers || l.approvers.length === 0)) {
1022
1023
  errors.push({
1023
1024
  field: `levels[${i}].approvers`,
1024
1025
  message: `Level ${l.level} must have at least one approver.`
1025
1026
  });
1026
1027
  }
1028
+ if (l.subWorkflow) {
1029
+ if (!l.subWorkflow.templateName) {
1030
+ errors.push({
1031
+ field: `levels[${i}].subWorkflow.templateName`,
1032
+ message: `Level ${l.level} declares a subWorkflow without a templateName.`
1033
+ });
1034
+ }
1035
+ if (l.subWorkflow.templateName === config.name) {
1036
+ errors.push({
1037
+ field: `levels[${i}].subWorkflow.templateName`,
1038
+ message: `Level ${l.level} would spawn a sub-workflow of its own template ("${config.name}"), which cannot terminate.`
1039
+ });
1040
+ }
1041
+ if (l.approvers && l.approvers.length > 0) {
1042
+ errors.push({
1043
+ field: `levels[${i}].approvers`,
1044
+ message: `Level ${l.level} sets both approvers and subWorkflow; a sub-workflow level is decided by its child approval, so its approvers would never be asked.`
1045
+ });
1046
+ }
1047
+ }
1027
1048
  if (l.reminderAfterDays !== void 0 && l.reminderAfterDays <= 0) {
1028
1049
  errors.push({
1029
1050
  field: `levels[${i}].reminderAfterDays`,
@@ -1166,7 +1187,7 @@ var ApprovalEngine = class _ApprovalEngine {
1166
1187
  return this.registry.list();
1167
1188
  }
1168
1189
  // ─── Lifecycle ────────────────────────────────────────────────────────────
1169
- async submit(raw, auditCtx) {
1190
+ async submit(raw, auditCtx, link) {
1170
1191
  const opts = parseOrThrow(() => SubmitOptionsSchema.parse(raw));
1171
1192
  const startMs = this.clock.now().getTime();
1172
1193
  const template = await this.registry.get(opts.templateName);
@@ -1226,6 +1247,7 @@ var ApprovalEngine = class _ApprovalEngine {
1226
1247
  weights: cfg.weights,
1227
1248
  escalationAfterDays: cfg.escalationAfterDays,
1228
1249
  escalationDueAt: inFirstGroup && cfg.escalationAfterDays ? this.deadlineFrom(now, cfg.escalationAfterDays) : void 0,
1250
+ subWorkflowTemplate: cfg.subWorkflow?.templateName,
1229
1251
  reminderAfterDays: cfg.reminderAfterDays,
1230
1252
  reminderEveryDays: cfg.reminderEveryDays,
1231
1253
  maxReminders: cfg.maxReminders,
@@ -1234,6 +1256,10 @@ var ApprovalEngine = class _ApprovalEngine {
1234
1256
  };
1235
1257
  });
1236
1258
  for (const lvl of levels.filter((l) => l.status === "pending")) {
1259
+ if (lvl.subWorkflowTemplate) {
1260
+ lvl.approverIds = [];
1261
+ continue;
1262
+ }
1237
1263
  lvl.approverIds = await this.resolver.resolveApprovers(
1238
1264
  lvl.approverConfigs,
1239
1265
  opts.submittedBy,
@@ -1254,6 +1280,9 @@ var ApprovalEngine = class _ApprovalEngine {
1254
1280
  const instance = {
1255
1281
  id: instanceId,
1256
1282
  tenantId: this.tenantId,
1283
+ parentInstanceId: link?.parentInstanceId,
1284
+ parentLevel: link?.parentLevel,
1285
+ subWorkflowDepth: link?.depth,
1257
1286
  templateId: template.id,
1258
1287
  templateName: template.name,
1259
1288
  documentId: opts.documentId,
@@ -1318,12 +1347,13 @@ var ApprovalEngine = class _ApprovalEngine {
1318
1347
  { operation: "submit", actorId: opts.submittedBy, tenantId: this.tenantId, input: opts },
1319
1348
  instance
1320
1349
  );
1350
+ await this.startSubWorkflows(instance);
1321
1351
  return instance;
1322
1352
  }
1323
1353
  async approve(instanceId, raw, auditCtx) {
1324
1354
  const opts = parseOrThrow(() => ApproveOptionsSchema.parse(raw));
1325
1355
  const startMs = this.clock.now().getTime();
1326
- return this.withOptimisticRetry(instanceId, async (instance) => {
1356
+ const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
1327
1357
  assertStatus(instance, "pending");
1328
1358
  if (opts.approverId === instance.submittedBy) {
1329
1359
  throw new ApprovalForbiddenError(
@@ -1536,10 +1566,12 @@ var ApprovalEngine = class _ApprovalEngine {
1536
1566
  }
1537
1567
  return instance;
1538
1568
  });
1569
+ await this.afterDecision(decided);
1570
+ return decided;
1539
1571
  }
1540
1572
  async reject(instanceId, raw, auditCtx) {
1541
1573
  const opts = parseOrThrow(() => RejectOptionsSchema.parse(raw));
1542
- return this.withOptimisticRetry(instanceId, async (instance) => {
1574
+ const decided = await this.withOptimisticRetry(instanceId, async (instance) => {
1543
1575
  assertStatus(instance, "pending");
1544
1576
  if (opts.approverId === instance.submittedBy) {
1545
1577
  throw new ApprovalForbiddenError(
@@ -1658,6 +1690,8 @@ var ApprovalEngine = class _ApprovalEngine {
1658
1690
  }
1659
1691
  return instance;
1660
1692
  });
1693
+ await this.afterDecision(decided);
1694
+ return decided;
1661
1695
  }
1662
1696
  async delegate(instanceId, raw, auditCtx) {
1663
1697
  const opts = parseOrThrow(() => DelegateOptionsSchema.parse(raw));
@@ -1835,7 +1869,8 @@ var ApprovalEngine = class _ApprovalEngine {
1835
1869
  }
1836
1870
  async cancel(instanceId, raw, auditCtx) {
1837
1871
  const opts = parseOrThrow(() => CancelOptionsSchema.parse(raw));
1838
- return this.withOptimisticRetry(instanceId, async (instance) => {
1872
+ let cancelled = null;
1873
+ const result = await this.withOptimisticRetry(instanceId, async (instance) => {
1839
1874
  if (instance.status === "approved" || instance.status === "rejected") {
1840
1875
  throw new ApprovalError(`Cannot cancel a "${instance.status}" approval.`, "CANNOT_CANCEL");
1841
1876
  }
@@ -1889,8 +1924,11 @@ var ApprovalEngine = class _ApprovalEngine {
1889
1924
  },
1890
1925
  instance
1891
1926
  );
1927
+ cancelled = instance;
1892
1928
  return instance;
1893
1929
  });
1930
+ if (cancelled) await this.propagateToParent(cancelled);
1931
+ return result;
1894
1932
  }
1895
1933
  async escalate(instanceId, raw, auditCtx) {
1896
1934
  parseOrThrow(() => EscalateOptionsSchema.parse(raw));
@@ -3407,14 +3445,18 @@ var ApprovalEngine = class _ApprovalEngine {
3407
3445
  /** Resolve approvers for a group, set its deadlines, and mark it pending. */
3408
3446
  async activateGroup(instance, group, now) {
3409
3447
  for (const lvl of group) {
3410
- lvl.approverIds = await this.resolver.resolveApprovers(
3411
- lvl.approverConfigs,
3412
- instance.submittedBy,
3413
- instance.data,
3414
- this.opts.orgProvider,
3415
- this.opts.outOfOfficeProvider,
3416
- now
3417
- );
3448
+ if (lvl.subWorkflowTemplate) {
3449
+ lvl.approverIds = [];
3450
+ } else {
3451
+ lvl.approverIds = await this.resolver.resolveApprovers(
3452
+ lvl.approverConfigs,
3453
+ instance.submittedBy,
3454
+ instance.data,
3455
+ this.opts.orgProvider,
3456
+ this.opts.outOfOfficeProvider,
3457
+ now
3458
+ );
3459
+ }
3418
3460
  if (lvl.escalationAfterDays) {
3419
3461
  lvl.escalationDueAt = this.deadlineFrom(now, lvl.escalationAfterDays);
3420
3462
  }
@@ -3501,6 +3543,222 @@ var ApprovalEngine = class _ApprovalEngine {
3501
3543
  this.logger.error("reminder: failed to send", err, { tenantId: this.tenantId, instanceId });
3502
3544
  }
3503
3545
  }
3546
+ /**
3547
+ * Start child approvals for any open level that delegates to a sub-workflow.
3548
+ *
3549
+ * Run after the parent has been persisted, never inside the same optimistic
3550
+ * write: the child's own `submit()` performs its own reads and writes, and
3551
+ * nesting them under the parent's compare-and-set would make a slow child
3552
+ * template a source of spurious version conflicts on the parent.
3553
+ */
3554
+ async startSubWorkflows(instance) {
3555
+ const pendingSubs = instance.levels.filter(
3556
+ (l) => l.status === "pending" && l.subWorkflowTemplate && !l.childInstanceId
3557
+ );
3558
+ if (pendingSubs.length === 0) return;
3559
+ const depth = (instance.subWorkflowDepth ?? 0) + 1;
3560
+ if (depth > MAX_SUBWORKFLOW_DEPTH) {
3561
+ throw new ApprovalValidationError(
3562
+ `Sub-workflow nesting exceeded ${MAX_SUBWORKFLOW_DEPTH} levels at template "${instance.templateName}". Check for a template that reaches itself.`
3563
+ );
3564
+ }
3565
+ for (const level of pendingSubs) {
3566
+ const templateName = level.subWorkflowTemplate;
3567
+ const childTemplate = await this.registry.get(templateName);
3568
+ const child = await this.submit(
3569
+ {
3570
+ templateName,
3571
+ // Unique per parent level, so a resubmitted parent does not collide
3572
+ // with the child it spawned last time.
3573
+ documentId: `${instance.documentId}#L${level.level}`,
3574
+ documentType: childTemplate.documentType,
3575
+ submittedBy: instance.submittedBy,
3576
+ data: instance.data,
3577
+ metadata: { ...instance.metadata, parentInstanceId: instance.id }
3578
+ },
3579
+ void 0,
3580
+ { parentInstanceId: instance.id, parentLevel: level.level, depth }
3581
+ );
3582
+ await this.withOptimisticRetry(instance.id, async (parent) => {
3583
+ const lvl = parent.levels.find((l) => l.level === level.level);
3584
+ if (!lvl || lvl.childInstanceId) return parent;
3585
+ lvl.childInstanceId = child.id;
3586
+ parent.updatedAt = this.clock.now();
3587
+ await this.opts.adapter.updateInstance(parent, parent.version);
3588
+ return parent;
3589
+ });
3590
+ const payload = {
3591
+ instanceId: instance.id,
3592
+ documentId: instance.documentId,
3593
+ documentType: instance.documentType,
3594
+ timestamp: this.clock.now(),
3595
+ level: level.level,
3596
+ childInstanceId: child.id,
3597
+ childTemplateName: templateName
3598
+ };
3599
+ this.bus.emit("approval:subworkflow_started", payload);
3600
+ await this.notifyAdapters("approval:subworkflow_started", instance, payload);
3601
+ this.logger.info("subWorkflow: child approval started", {
3602
+ tenantId: this.tenantId,
3603
+ instanceId: instance.id,
3604
+ level: level.level,
3605
+ childInstanceId: child.id
3606
+ });
3607
+ }
3608
+ }
3609
+ /**
3610
+ * Return a finished child's outcome to the parent level that is waiting on it.
3611
+ *
3612
+ * An approved child approves its parent level and lets the chain advance; any
3613
+ * other terminal outcome — rejected, cancelled, expired — rejects the parent,
3614
+ * because the approval the parent was waiting for did not happen. Collapsing
3615
+ * those into one rejection is deliberate: a parent that treated a cancelled
3616
+ * child as "carry on" would advance past a gate nobody cleared.
3617
+ */
3618
+ async propagateToParent(child) {
3619
+ if (!child.parentInstanceId || child.parentLevel === void 0) return;
3620
+ const outcome = child.status;
3621
+ const parentId = child.parentInstanceId;
3622
+ const parentLevelNumber = child.parentLevel;
3623
+ try {
3624
+ const parent = await this.opts.adapter.getInstance(this.tenantId, parentId);
3625
+ if (!parent || parent.status !== "pending") return;
3626
+ const payload = {
3627
+ instanceId: parentId,
3628
+ documentId: parent.documentId,
3629
+ documentType: parent.documentType,
3630
+ timestamp: this.clock.now(),
3631
+ level: parentLevelNumber,
3632
+ childInstanceId: child.id,
3633
+ childTemplateName: child.templateName,
3634
+ outcome
3635
+ };
3636
+ if (outcome === "approved") {
3637
+ await this.completeSubWorkflowLevel(parentId, parentLevelNumber, child);
3638
+ } else {
3639
+ await this.rejectFromSubWorkflow(parentId, parentLevelNumber, child);
3640
+ }
3641
+ const refreshed = await this.opts.adapter.getInstance(this.tenantId, parentId);
3642
+ this.bus.emit("approval:subworkflow_completed", payload);
3643
+ if (refreshed) {
3644
+ await this.notifyAdapters("approval:subworkflow_completed", refreshed, payload);
3645
+ }
3646
+ } catch (err) {
3647
+ this.logger.error("subWorkflow: failed to propagate outcome to parent", err, {
3648
+ tenantId: this.tenantId,
3649
+ childInstanceId: child.id,
3650
+ parentInstanceId: parentId
3651
+ });
3652
+ }
3653
+ }
3654
+ /** Mark a sub-workflow level approved and advance the parent chain. */
3655
+ async completeSubWorkflowLevel(parentId, levelNumber, child) {
3656
+ let advanced = null;
3657
+ await this.withOptimisticRetry(parentId, async (parent) => {
3658
+ const level = parent.levels.find((l) => l.level === levelNumber);
3659
+ if (!level || level.status !== "pending") return parent;
3660
+ const now = this.clock.now();
3661
+ level.status = "approved";
3662
+ level.reminderDueAt = void 0;
3663
+ level.escalationDueAt = void 0;
3664
+ const auditEntry = {
3665
+ action: "subworkflow_completed",
3666
+ actorId: "system",
3667
+ level: levelNumber,
3668
+ timestamp: now,
3669
+ newValue: { childInstanceId: child.id, outcome: "approved" }
3670
+ };
3671
+ parent.auditLog.push(auditEntry);
3672
+ parent.updatedAt = now;
3673
+ const siblingsOpen = this.groupMembers(parent, level).some(
3674
+ (l) => l.status === "pending" || l.status === "waiting"
3675
+ );
3676
+ if (!siblingsOpen) {
3677
+ const nextGroup = this.findNextGroup(parent);
3678
+ if (nextGroup.length === 0) {
3679
+ parent.status = "approved";
3680
+ } else {
3681
+ await this.activateGroup(parent, nextGroup, now);
3682
+ }
3683
+ }
3684
+ await this.opts.adapter.updateInstance(parent, parent.version);
3685
+ await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
3686
+ await this.runExternalAudit(parent, auditEntry);
3687
+ advanced = parent;
3688
+ return parent;
3689
+ });
3690
+ if (advanced) {
3691
+ const parent = advanced;
3692
+ if (parent.status === "approved") {
3693
+ this.bus.emit("approval:completed", parent);
3694
+ await this.notifyAdapters("approval:completed", parent, parent);
3695
+ await this.propagateToParent(parent);
3696
+ } else {
3697
+ await this.startSubWorkflows(parent);
3698
+ }
3699
+ }
3700
+ }
3701
+ /** Reject a parent because the child approval it was waiting on did not succeed. */
3702
+ async rejectFromSubWorkflow(parentId, levelNumber, child) {
3703
+ let rejected = null;
3704
+ await this.withOptimisticRetry(parentId, async (parent) => {
3705
+ const level = parent.levels.find((l) => l.level === levelNumber);
3706
+ if (!level || level.status !== "pending") return parent;
3707
+ const now = this.clock.now();
3708
+ level.status = "rejected";
3709
+ level.reminderDueAt = void 0;
3710
+ level.escalationDueAt = void 0;
3711
+ parent.status = "rejected";
3712
+ parent.updatedAt = now;
3713
+ const auditEntry = {
3714
+ action: "subworkflow_completed",
3715
+ actorId: "system",
3716
+ level: levelNumber,
3717
+ timestamp: now,
3718
+ reason: `Child approval ${child.id} ended as "${child.status}".`,
3719
+ newValue: { childInstanceId: child.id, outcome: child.status }
3720
+ };
3721
+ parent.auditLog.push(auditEntry);
3722
+ await this.opts.adapter.updateInstance(parent, parent.version);
3723
+ await this.opts.adapter.appendAuditEntry(this.tenantId, parentId, auditEntry);
3724
+ await this.runExternalAudit(parent, auditEntry);
3725
+ rejected = parent;
3726
+ return parent;
3727
+ });
3728
+ if (rejected) {
3729
+ const parent = rejected;
3730
+ const payload = {
3731
+ instanceId: parent.id,
3732
+ documentId: parent.documentId,
3733
+ documentType: parent.documentType,
3734
+ timestamp: this.clock.now(),
3735
+ approverId: "system",
3736
+ level: levelNumber,
3737
+ reason: `Child approval ${child.id} ended as "${child.status}".`,
3738
+ returnTo: null
3739
+ };
3740
+ this.bus.emit("approval:rejected", payload);
3741
+ await this.notifyAdapters("approval:rejected", parent, payload);
3742
+ await this.propagateToParent(parent);
3743
+ }
3744
+ }
3745
+ /**
3746
+ * Work that must happen after a decision is durably recorded, not inside it.
3747
+ *
3748
+ * Both branches touch other instances — a newly opened level may spawn a
3749
+ * child approval, and a finished instance may be a child that owes its
3750
+ * outcome to a parent. Doing either inside the deciding instance's
3751
+ * compare-and-set would nest writes under a version guard that knows nothing
3752
+ * about them, so a slow child template would surface as a spurious conflict
3753
+ * on the decision the user just made.
3754
+ */
3755
+ async afterDecision(instance) {
3756
+ if (TERMINAL_STATUSES.has(instance.status)) {
3757
+ await this.propagateToParent(instance);
3758
+ return;
3759
+ }
3760
+ await this.startSubWorkflows(instance);
3761
+ }
3504
3762
  findNextLevel(instance) {
3505
3763
  return instance.levels.find((l) => l.level > instance.currentLevel && l.status === "waiting") ?? null;
3506
3764
  }