hierarchical-approval 2.1.0 → 2.2.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,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  _Nothing yet._
9
9
 
10
+ ## [2.2.0] - 2026-09-04
11
+
12
+ ### Added — template export / import
13
+
14
+ - **`exportTemplates()` and `importTemplates()` move approval configuration
15
+ between environments.** Templates are authored in a sandbox, reviewed, then
16
+ promoted — but the only way to carry them across was to read `listTemplates()`
17
+ and re-post the rows, which dragged each environment's own `id`, `tenantId`
18
+ and version lineage along. Those either collided on arrival or silently
19
+ claimed a history the target never had.
20
+
21
+ ```ts
22
+ const bundle = await sandbox.exportTemplates(['PO', 'INV']);
23
+ await production.importTemplates(bundle, { mode: 'upsert', dryRun: true });
24
+ ```
25
+
26
+ A bundle is plain JSON, version-stamped, and carries no environment-specific
27
+ fields — they are stripped, not blanked, so a round trip cannot reintroduce a
28
+ stale id. The target assigns its own identity.
29
+
30
+ - **`mode: 'create'`** (default) skips templates that already exist;
31
+ **`'upsert'`** updates them, bumping the version and recording
32
+ `previousVersionId` exactly as `updateTemplate()` does. `dryRun` reports
33
+ without writing.
34
+
35
+ - **Every template is validated before any is written.** A half-applied bundle
36
+ is worse than one rejected outright: the tenant ends up matching neither
37
+ environment and the operator cannot tell which half landed. Validation
38
+ failures reject the whole bundle and name the offending template; storage
39
+ errors during the write phase are still reported per template, since those can
40
+ occur after validation passes.
41
+
42
+ Import also rejects an unsupported `bundleVersion`, an empty bundle, and
43
+ duplicate names within one bundle.
44
+
45
+ New exports: `TemplateBundle`, `ImportResult`, `TEMPLATE_BUNDLE_VERSION`.
46
+
10
47
  ## [2.1.0] - 2026-09-04
11
48
 
12
49
  ### Added — sub-workflows
package/README.md CHANGED
@@ -468,6 +468,32 @@ The sweep is **not atomic**: it reports per-instance failures rather than rollin
468
468
  back. A partial transfer is the useful outcome — the approvals that can move
469
469
  should move, and the ones that cannot are named so a human can look at them.
470
470
 
471
+ ### Promoting templates between environments
472
+
473
+ Approval configuration is authored somewhere safe, reviewed, then promoted.
474
+ `exportTemplates()` produces a portable bundle and `importTemplates()` applies
475
+ it:
476
+
477
+ ```ts
478
+ const bundle = await sandbox.exportTemplates(['PO', 'INV']); // omit names for all
479
+ const result = await production.importTemplates(bundle, { mode: 'upsert', dryRun: true });
480
+ // { created: [], updated: ['PO', 'INV'], skipped: [], errors: [], dryRun: true }
481
+ ```
482
+
483
+ A bundle is plain JSON and carries **no** `id`, `tenantId`, `createdAt`,
484
+ `version` or `previousVersionId` — those describe one row in one database, and
485
+ importing them would either collide with the target's ids or claim a lineage the
486
+ target never had. The target assigns its own.
487
+
488
+ `mode: 'create'` (the default) skips templates that already exist; `'upsert'`
489
+ updates them, bumping the version and recording `previousVersionId` as a normal
490
+ `updateTemplate()` would.
491
+
492
+ **Every template is validated before any is written.** A half-applied bundle
493
+ leaves the tenant matching neither environment with no way to tell which half
494
+ landed, so a bundle that fails validation is rejected whole, naming the
495
+ offending template.
496
+
471
497
  ### Sub-workflows
472
498
 
473
499
  A level can delegate to a whole separate approval instead of to a list of
@@ -305,6 +305,32 @@ interface ApproverWorkload {
305
305
  /** Age of that oldest item. `0` when they hold nothing. */
306
306
  oldestAgeMs: number;
307
307
  }
308
+ /** Version stamp on an exported bundle, so an importer can reject a shape it does not understand. */
309
+ declare const TEMPLATE_BUNDLE_VERSION = 1;
310
+ /**
311
+ * A portable set of templates, safe to move between environments.
312
+ *
313
+ * Deliberately carries no `id`, `tenantId`, `createdAt`, `version` or
314
+ * `previousVersionId`: those describe one row in one database, and importing
315
+ * them would either collide with the target's own ids or silently claim a
316
+ * lineage the target never had.
317
+ */
318
+ interface TemplateBundle {
319
+ bundleVersion: number;
320
+ exportedAt: Date;
321
+ templates: ApprovalTemplateConfig[];
322
+ }
323
+ /** Outcome of {@link ApprovalEngine.importTemplates}. */
324
+ interface ImportResult {
325
+ created: string[];
326
+ updated: string[];
327
+ skipped: string[];
328
+ errors: Array<{
329
+ name: string;
330
+ message: string;
331
+ }>;
332
+ dryRun: boolean;
333
+ }
308
334
  interface ApprovalStatistics {
309
335
  /** Total instances matching the filter (across all statuses). */
310
336
  total: number;
@@ -660,6 +686,36 @@ declare class ApprovalEngine {
660
686
  * @returns One row per approver holding at least one open level.
661
687
  */
662
688
  getWorkload(filter?: Omit<InstanceFilter, 'status'>): Promise<ApproverWorkload[]>;
689
+ /**
690
+ * Export templates as a portable bundle.
691
+ *
692
+ * Approval configuration is written once and then has to travel — authored in
693
+ * a sandbox, reviewed, promoted to production. Reading `listTemplates()` and
694
+ * re-posting the rows carried each environment's own `id`, `tenantId` and
695
+ * version lineage with it, which either collided on arrival or silently
696
+ * claimed a history the target never had. This strips all of it.
697
+ *
698
+ * @param names - Templates to include; omit for all of them.
699
+ */
700
+ exportTemplates(names?: string[]): Promise<TemplateBundle>;
701
+ /**
702
+ * Import a bundle produced by {@link exportTemplates}.
703
+ *
704
+ * **Every template is validated before any is written.** A bundle that is
705
+ * half-applied is worse than one rejected outright: the tenant is left in a
706
+ * state matching neither environment, and the operator has no way to tell
707
+ * which half landed. Per-template failures during the write phase are still
708
+ * reported individually, since a storage error can occur after validation
709
+ * passes.
710
+ *
711
+ * @param bundle - The bundle to apply.
712
+ * @param opts - `mode: 'create'` (default) refuses to touch existing
713
+ * templates; `'upsert'` updates them. `dryRun` reports without writing.
714
+ */
715
+ importTemplates(bundle: TemplateBundle, opts?: {
716
+ mode?: 'create' | 'upsert';
717
+ dryRun?: boolean;
718
+ }): Promise<ImportResult>;
663
719
  getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
664
720
  shutdown(): Promise<void>;
665
721
  /**
@@ -791,4 +847,4 @@ declare class ApprovalEngine {
791
847
  private runExternalAudit;
792
848
  }
793
849
 
794
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type TransferResult as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type ApproverWorkload as f, type BusinessCalendar as g, type CancelOptions as h, type ConditionOperatorFn as i, type CycleTimeStats as j, type IdempotencyKeyFn as k, type OutOfOfficeProvider as l, type OverrideOptions as m, type PreviewResult as n, type ProvideInfoOptions as o, type RejectOptions as p, type RequestInfoOptions as q, type ResubmitOptions as r, type RetryPolicy as s, defaultIdGenerator as t, toComparableNumber as u, validateConditionExpression as v, weekendCalendar as w };
850
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, TEMPLATE_BUNDLE_VERSION as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type ApproverWorkload as f, type BusinessCalendar as g, type CancelOptions as h, type ConditionOperatorFn as i, type CycleTimeStats as j, type IdempotencyKeyFn as k, type ImportResult as l, type OutOfOfficeProvider as m, type OverrideOptions as n, type PreviewResult as o, type ProvideInfoOptions as p, type RejectOptions as q, type RequestInfoOptions as r, type ResubmitOptions as s, type RetryPolicy as t, type TemplateBundle as u, type TransferResult as v, defaultIdGenerator as w, toComparableNumber as x, validateConditionExpression as y, weekendCalendar as z };
@@ -305,6 +305,32 @@ interface ApproverWorkload {
305
305
  /** Age of that oldest item. `0` when they hold nothing. */
306
306
  oldestAgeMs: number;
307
307
  }
308
+ /** Version stamp on an exported bundle, so an importer can reject a shape it does not understand. */
309
+ declare const TEMPLATE_BUNDLE_VERSION = 1;
310
+ /**
311
+ * A portable set of templates, safe to move between environments.
312
+ *
313
+ * Deliberately carries no `id`, `tenantId`, `createdAt`, `version` or
314
+ * `previousVersionId`: those describe one row in one database, and importing
315
+ * them would either collide with the target's own ids or silently claim a
316
+ * lineage the target never had.
317
+ */
318
+ interface TemplateBundle {
319
+ bundleVersion: number;
320
+ exportedAt: Date;
321
+ templates: ApprovalTemplateConfig[];
322
+ }
323
+ /** Outcome of {@link ApprovalEngine.importTemplates}. */
324
+ interface ImportResult {
325
+ created: string[];
326
+ updated: string[];
327
+ skipped: string[];
328
+ errors: Array<{
329
+ name: string;
330
+ message: string;
331
+ }>;
332
+ dryRun: boolean;
333
+ }
308
334
  interface ApprovalStatistics {
309
335
  /** Total instances matching the filter (across all statuses). */
310
336
  total: number;
@@ -660,6 +686,36 @@ declare class ApprovalEngine {
660
686
  * @returns One row per approver holding at least one open level.
661
687
  */
662
688
  getWorkload(filter?: Omit<InstanceFilter, 'status'>): Promise<ApproverWorkload[]>;
689
+ /**
690
+ * Export templates as a portable bundle.
691
+ *
692
+ * Approval configuration is written once and then has to travel — authored in
693
+ * a sandbox, reviewed, promoted to production. Reading `listTemplates()` and
694
+ * re-posting the rows carried each environment's own `id`, `tenantId` and
695
+ * version lineage with it, which either collided on arrival or silently
696
+ * claimed a history the target never had. This strips all of it.
697
+ *
698
+ * @param names - Templates to include; omit for all of them.
699
+ */
700
+ exportTemplates(names?: string[]): Promise<TemplateBundle>;
701
+ /**
702
+ * Import a bundle produced by {@link exportTemplates}.
703
+ *
704
+ * **Every template is validated before any is written.** A bundle that is
705
+ * half-applied is worse than one rejected outright: the tenant is left in a
706
+ * state matching neither environment, and the operator has no way to tell
707
+ * which half landed. Per-template failures during the write phase are still
708
+ * reported individually, since a storage error can occur after validation
709
+ * passes.
710
+ *
711
+ * @param bundle - The bundle to apply.
712
+ * @param opts - `mode: 'create'` (default) refuses to touch existing
713
+ * templates; `'upsert'` updates them. `dryRun` reports without writing.
714
+ */
715
+ importTemplates(bundle: TemplateBundle, opts?: {
716
+ mode?: 'create' | 'upsert';
717
+ dryRun?: boolean;
718
+ }): Promise<ImportResult>;
663
719
  getStatistics(filter?: Omit<InstanceFilter, 'status'>): Promise<ApprovalStatistics>;
664
720
  shutdown(): Promise<void>;
665
721
  /**
@@ -791,4 +847,4 @@ declare class ApprovalEngine {
791
847
  private runExternalAudit;
792
848
  }
793
849
 
794
- export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, type TransferResult as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type ApproverWorkload as f, type BusinessCalendar as g, type CancelOptions as h, type ConditionOperatorFn as i, type CycleTimeStats as j, type IdempotencyKeyFn as k, type OutOfOfficeProvider as l, type OverrideOptions as m, type PreviewResult as n, type ProvideInfoOptions as o, type RejectOptions as p, type RequestInfoOptions as q, type ResubmitOptions as r, type RetryPolicy as s, defaultIdGenerator as t, toComparableNumber as u, validateConditionExpression as v, weekendCalendar as w };
850
+ export { type AddCommentOptions as A, type BulkResult as B, type CanApproveResult as C, type DelegateOptions as D, type EscalateOptions as E, type HealthResult as H, type IdGeneratorFn as I, type OrgProvider as O, type PreviewChainLevel as P, type ReassignOptions as R, type SubmitOptions as S, TEMPLATE_BUNDLE_VERSION as T, type UpdateDataOptions as U, type ValidationResult as V, type WeekendCalendarOptions as W, ApprovalEngine as a, type ApprovalEngineOptions as b, type ApprovalStatistics as c, type ApproveOptions as d, type ApproverResolverFn as e, type ApproverWorkload as f, type BusinessCalendar as g, type CancelOptions as h, type ConditionOperatorFn as i, type CycleTimeStats as j, type IdempotencyKeyFn as k, type ImportResult as l, type OutOfOfficeProvider as m, type OverrideOptions as n, type PreviewResult as o, type ProvideInfoOptions as p, type RejectOptions as q, type RequestInfoOptions as r, type ResubmitOptions as s, type RetryPolicy as t, type TemplateBundle as u, type TransferResult as v, defaultIdGenerator as w, toComparableNumber as x, validateConditionExpression as y, weekendCalendar as z };
package/dist/index.cjs CHANGED
@@ -926,6 +926,7 @@ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
926
926
  ]);
927
927
  var CYCLE_TIME_STATUSES = ["approved", "rejected", "cancelled"];
928
928
  var CYCLE_TIME_FETCH_BATCH_SIZE = 500;
929
+ var TEMPLATE_BUNDLE_VERSION = 1;
929
930
  var ApprovalEngine = class _ApprovalEngine {
930
931
  constructor(opts) {
931
932
  this.opts = opts;
@@ -3007,6 +3008,118 @@ var ApprovalEngine = class _ApprovalEngine {
3007
3008
  oldestAgeMs: Number.isFinite(row.oldest) ? now.getTime() - row.oldest : 0
3008
3009
  })).sort((a, b) => b.pending - a.pending || a.approverId.localeCompare(b.approverId));
3009
3010
  }
3011
+ /**
3012
+ * Export templates as a portable bundle.
3013
+ *
3014
+ * Approval configuration is written once and then has to travel — authored in
3015
+ * a sandbox, reviewed, promoted to production. Reading `listTemplates()` and
3016
+ * re-posting the rows carried each environment's own `id`, `tenantId` and
3017
+ * version lineage with it, which either collided on arrival or silently
3018
+ * claimed a history the target never had. This strips all of it.
3019
+ *
3020
+ * @param names - Templates to include; omit for all of them.
3021
+ */
3022
+ async exportTemplates(names) {
3023
+ const all = await this.registry.list();
3024
+ const wanted = names ? all.filter((t) => names.includes(t.name)) : all;
3025
+ if (names) {
3026
+ const missing = names.filter((n) => !all.some((t) => t.name === n));
3027
+ if (missing.length > 0) {
3028
+ throw new ApprovalTemplateNotFoundError(missing.join(", "));
3029
+ }
3030
+ }
3031
+ return {
3032
+ bundleVersion: TEMPLATE_BUNDLE_VERSION,
3033
+ exportedAt: this.clock.now(),
3034
+ templates: wanted.map((t) => {
3035
+ const {
3036
+ id: _id,
3037
+ tenantId: _tenantId,
3038
+ createdAt: _createdAt,
3039
+ version: _version,
3040
+ previousVersionId: _previousVersionId,
3041
+ ...config
3042
+ } = t;
3043
+ return config;
3044
+ })
3045
+ };
3046
+ }
3047
+ /**
3048
+ * Import a bundle produced by {@link exportTemplates}.
3049
+ *
3050
+ * **Every template is validated before any is written.** A bundle that is
3051
+ * half-applied is worse than one rejected outright: the tenant is left in a
3052
+ * state matching neither environment, and the operator has no way to tell
3053
+ * which half landed. Per-template failures during the write phase are still
3054
+ * reported individually, since a storage error can occur after validation
3055
+ * passes.
3056
+ *
3057
+ * @param bundle - The bundle to apply.
3058
+ * @param opts - `mode: 'create'` (default) refuses to touch existing
3059
+ * templates; `'upsert'` updates them. `dryRun` reports without writing.
3060
+ */
3061
+ async importTemplates(bundle, opts = {}) {
3062
+ const mode = opts.mode ?? "create";
3063
+ const dryRun = opts.dryRun ?? false;
3064
+ if (bundle.bundleVersion !== TEMPLATE_BUNDLE_VERSION) {
3065
+ throw new ApprovalValidationError(
3066
+ `Unsupported template bundle version ${bundle.bundleVersion}; this engine reads version ${TEMPLATE_BUNDLE_VERSION}.`
3067
+ );
3068
+ }
3069
+ if (!Array.isArray(bundle.templates) || bundle.templates.length === 0) {
3070
+ throw new ApprovalValidationError("Template bundle contains no templates.");
3071
+ }
3072
+ const duplicates = bundle.templates.map((t) => t.name).filter((name, i, all) => all.indexOf(name) !== i);
3073
+ if (duplicates.length > 0) {
3074
+ throw new ApprovalValidationError(
3075
+ `Template bundle contains duplicate names: ${[...new Set(duplicates)].join(", ")}.`
3076
+ );
3077
+ }
3078
+ const invalid = [];
3079
+ for (const config of bundle.templates) {
3080
+ const result2 = this.validateTemplate(config);
3081
+ if (!result2.valid) {
3082
+ invalid.push({
3083
+ name: config.name,
3084
+ message: result2.errors[0]?.message ?? "unknown validation error"
3085
+ });
3086
+ }
3087
+ }
3088
+ if (invalid.length > 0) {
3089
+ throw new ApprovalValidationError(
3090
+ `Template bundle failed validation and was not applied: ${invalid.map((e) => `${e.name}: ${e.message}`).join("; ")}`
3091
+ );
3092
+ }
3093
+ const result = { created: [], updated: [], skipped: [], errors: [], dryRun };
3094
+ for (const config of bundle.templates) {
3095
+ const existing = await this.opts.adapter.getTemplate(this.tenantId, config.name);
3096
+ try {
3097
+ if (existing && mode === "create") {
3098
+ result.skipped.push(config.name);
3099
+ continue;
3100
+ }
3101
+ if (existing) {
3102
+ if (!dryRun) await this.registry.update(config);
3103
+ result.updated.push(config.name);
3104
+ } else {
3105
+ if (!dryRun) await this.registry.define(config);
3106
+ result.created.push(config.name);
3107
+ }
3108
+ } catch (err) {
3109
+ result.errors.push({ name: config.name, message: err.message });
3110
+ }
3111
+ }
3112
+ this.logger.info("importTemplates: bundle applied", {
3113
+ tenantId: this.tenantId,
3114
+ mode,
3115
+ dryRun,
3116
+ created: result.created.length,
3117
+ updated: result.updated.length,
3118
+ skipped: result.skipped.length,
3119
+ errors: result.errors.length
3120
+ });
3121
+ return result;
3122
+ }
3010
3123
  async getStatistics(filter = {}) {
3011
3124
  const statuses = [
3012
3125
  "pending",
@@ -4106,6 +4219,7 @@ exports.ApprovalTemplateNotFoundError = ApprovalTemplateNotFoundError;
4106
4219
  exports.ApprovalValidationError = ApprovalValidationError;
4107
4220
  exports.EscalationScheduler = EscalationScheduler;
4108
4221
  exports.MemoryAdapter = MemoryAdapter;
4222
+ exports.TEMPLATE_BUNDLE_VERSION = TEMPLATE_BUNDLE_VERSION;
4109
4223
  exports.defaultIdGenerator = defaultIdGenerator;
4110
4224
  exports.noopLogger = noopLogger;
4111
4225
  exports.systemClock = systemClock;