@objectstack/plugin-approvals 5.0.0 → 5.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/plugin-approvals@5.0.0 build /home/runner/work/framework/framework/packages/plugins/plugin-approvals
2
+ > @objectstack/plugin-approvals@5.2.0 build /home/runner/work/framework/framework/packages/plugins/plugin-approvals
3
3
  > tsup --config ../../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- ESM dist/index.mjs 39.71 KB
14
- ESM dist/index.mjs.map 85.21 KB
15
- ESM ⚡️ Build success in 113ms
16
- CJS dist/index.js 40.77 KB
17
- CJS dist/index.js.map 85.13 KB
18
- CJS ⚡️ Build success in 113ms
13
+ ESM dist/index.mjs 42.73 KB
14
+ ESM dist/index.mjs.map 91.14 KB
15
+ ESM ⚡️ Build success in 140ms
16
+ CJS dist/index.js 43.79 KB
17
+ CJS dist/index.js.map 91.06 KB
18
+ CJS ⚡️ Build success in 146ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 16290ms
21
- DTS dist/index.d.mts 7.87 KB
22
- DTS dist/index.d.ts 7.87 KB
20
+ DTS ⚡️ Build success in 19856ms
21
+ DTS dist/index.d.mts 9.33 KB
22
+ DTS dist/index.d.ts 9.33 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  # @objectstack/plugin-approvals
2
2
 
3
+ ## 5.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bab2b20: feat(approvals): execution-pinned approval processes (ADR-0009)
8
+
9
+ When an approval request is submitted, the engine now records a `process_hash`
10
+ on `sys_approval_request` — the sha256 of the approval process body resolved
11
+ through `MetadataRepository`. While the request is in flight, `approve` /
12
+ `reject` / `recall` resolve the pinned process body via
13
+ `MetadataRepository.getByHash`. Upgrading the approval process definition
14
+ mid-flight therefore no longer affects requests that already started against
15
+ the previous version.
16
+
17
+ Behavior:
18
+
19
+ - `sys_approval_request` gains a `process_hash` column (text, nullable,
20
+ read-only). Existing rows keep working — the engine falls back to the
21
+ current `sys_approval_process` projection when the column is empty.
22
+ - `ApprovalServiceOptions` accepts an optional `metadataRepo`. When omitted
23
+ (e.g. defining processes purely through the runtime API or in unit tests),
24
+ pinning is silently disabled and the service behaves as before.
25
+ - `ApprovalsServicePlugin` looks up the metadata service from the kernel
26
+ and wires its repository automatically.
27
+ - The metadata-core local `MetadataTypeSchema` enum was realigned with the
28
+ canonical `@objectstack/spec/kernel` enum (drift fix: `approval`, `field`,
29
+ `function`, `service`, …).
30
+
31
+ This is the first user-visible consumer of the `executionPinned` capability
32
+ introduced in ADR-0009.
33
+
34
+ ### Patch Changes
35
+
36
+ - Updated dependencies [bab2b20]
37
+ - Updated dependencies [fa011d8]
38
+ - Updated dependencies [f0f7c27]
39
+ - Updated dependencies [b806f58]
40
+ - @objectstack/platform-objects@5.2.0
41
+ - @objectstack/spec@5.2.0
42
+ - @objectstack/metadata-core@5.2.0
43
+ - @objectstack/core@5.2.0
44
+ - @objectstack/formula@5.2.0
45
+
46
+ ## 5.1.0
47
+
48
+ ### Patch Changes
49
+
50
+ - Updated dependencies [75f4ee6]
51
+ - Updated dependencies [823d559]
52
+ - @objectstack/spec@5.1.0
53
+ - @objectstack/platform-objects@5.1.0
54
+ - @objectstack/core@5.1.0
55
+ - @objectstack/formula@5.1.0
56
+
3
57
  ## 5.0.0
4
58
 
5
59
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { SysApprovalAction, SysApprovalProcess, SysApprovalRequest } from '@objectstack/platform-objects/audit';
2
2
  import { IApprovalService, DefineApprovalProcessInput, SharingExecutionContext, ApprovalProcessRow, SubmitApprovalInput, ApprovalRequestRow, ApprovalStatus, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalActionRow } from '@objectstack/spec/contracts';
3
3
  export { ApprovalActionRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalProcessRow, ApprovalRequestRow, ApprovalStatus, DefineApprovalProcessInput, IApprovalService, SubmitApprovalInput } from '@objectstack/spec/contracts';
4
+ import { MetadataRepository } from '@objectstack/metadata-core';
4
5
  import { Plugin, PluginContext } from '@objectstack/core';
5
6
 
6
7
  /**
@@ -70,6 +71,19 @@ interface ApprovalServiceOptions {
70
71
  * The plugin uses this to re-bind lifecycle hooks for auto-trigger / lock.
71
72
  */
72
73
  onRegistryChange?: () => void | Promise<void>;
74
+ /**
75
+ * Optional metadata repository for execution-pinned process resolution
76
+ * (ADR-0009). When provided:
77
+ *
78
+ * - `submit()` records the process body's sha256 on the request row.
79
+ * - `approve` / `reject` / `recall` resolve the pinned body via
80
+ * `MetadataRepository.getByHash` so process upgrades don't affect
81
+ * in-flight requests.
82
+ *
83
+ * When omitted, the service reads the current process from the
84
+ * `sys_approval_process` projection (pre-ADR-0009 behaviour).
85
+ */
86
+ metadataRepo?: MetadataRepository;
73
87
  }
74
88
  declare class ApprovalService implements IApprovalService {
75
89
  private readonly engine;
@@ -78,6 +92,7 @@ declare class ApprovalService implements IApprovalService {
78
92
  private readonly fetchImpl?;
79
93
  private readonly webhookTimeoutMs?;
80
94
  private readonly onRegistryChange?;
95
+ private readonly metadataRepo?;
81
96
  constructor(opts: ApprovalServiceOptions);
82
97
  /** Allow the plugin to attach a hook re-binding callback after construction. */
83
98
  setRegistryChangeHandler(handler: () => void | Promise<void>): void;
@@ -105,6 +120,24 @@ declare class ApprovalService implements IApprovalService {
105
120
  private expandRoleUsers;
106
121
  private lookupManager;
107
122
  private notifyRegistryChanged;
123
+ /**
124
+ * Look up the HEAD checksum of an approval process from the metadata repo
125
+ * (ADR-0009). Returns null when no repo is wired, no metadata exists for
126
+ * the name, or the lookup fails — callers MUST treat null as "do not pin"
127
+ * and fall back to the projection table.
128
+ */
129
+ private resolveProcessHash;
130
+ /**
131
+ * Resolve the approval process for an in-flight request, honouring
132
+ * ADR-0009 execution pinning when a `process_hash` is recorded.
133
+ *
134
+ * Resolution order:
135
+ * 1. If `req.process_hash` AND `metadataRepo` are set, try
136
+ * `getByHash` — return a row whose `definition` is the pinned body.
137
+ * 2. Otherwise (or on lookup failure) fall back to the current
138
+ * projection via `getProcess(req.process_name)`.
139
+ */
140
+ private loadProcessForRequest;
108
141
  /** Mirror request status onto `process.approvalStatusField` if configured. */
109
142
  private syncStatusField;
110
143
  /** Convenience wrapper that funnels every action invocation through the executor. */
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { SysApprovalAction, SysApprovalProcess, SysApprovalRequest } from '@objectstack/platform-objects/audit';
2
2
  import { IApprovalService, DefineApprovalProcessInput, SharingExecutionContext, ApprovalProcessRow, SubmitApprovalInput, ApprovalRequestRow, ApprovalStatus, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalActionRow } from '@objectstack/spec/contracts';
3
3
  export { ApprovalActionRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalProcessRow, ApprovalRequestRow, ApprovalStatus, DefineApprovalProcessInput, IApprovalService, SubmitApprovalInput } from '@objectstack/spec/contracts';
4
+ import { MetadataRepository } from '@objectstack/metadata-core';
4
5
  import { Plugin, PluginContext } from '@objectstack/core';
5
6
 
6
7
  /**
@@ -70,6 +71,19 @@ interface ApprovalServiceOptions {
70
71
  * The plugin uses this to re-bind lifecycle hooks for auto-trigger / lock.
71
72
  */
72
73
  onRegistryChange?: () => void | Promise<void>;
74
+ /**
75
+ * Optional metadata repository for execution-pinned process resolution
76
+ * (ADR-0009). When provided:
77
+ *
78
+ * - `submit()` records the process body's sha256 on the request row.
79
+ * - `approve` / `reject` / `recall` resolve the pinned body via
80
+ * `MetadataRepository.getByHash` so process upgrades don't affect
81
+ * in-flight requests.
82
+ *
83
+ * When omitted, the service reads the current process from the
84
+ * `sys_approval_process` projection (pre-ADR-0009 behaviour).
85
+ */
86
+ metadataRepo?: MetadataRepository;
73
87
  }
74
88
  declare class ApprovalService implements IApprovalService {
75
89
  private readonly engine;
@@ -78,6 +92,7 @@ declare class ApprovalService implements IApprovalService {
78
92
  private readonly fetchImpl?;
79
93
  private readonly webhookTimeoutMs?;
80
94
  private readonly onRegistryChange?;
95
+ private readonly metadataRepo?;
81
96
  constructor(opts: ApprovalServiceOptions);
82
97
  /** Allow the plugin to attach a hook re-binding callback after construction. */
83
98
  setRegistryChangeHandler(handler: () => void | Promise<void>): void;
@@ -105,6 +120,24 @@ declare class ApprovalService implements IApprovalService {
105
120
  private expandRoleUsers;
106
121
  private lookupManager;
107
122
  private notifyRegistryChanged;
123
+ /**
124
+ * Look up the HEAD checksum of an approval process from the metadata repo
125
+ * (ADR-0009). Returns null when no repo is wired, no metadata exists for
126
+ * the name, or the lookup fails — callers MUST treat null as "do not pin"
127
+ * and fall back to the projection table.
128
+ */
129
+ private resolveProcessHash;
130
+ /**
131
+ * Resolve the approval process for an in-flight request, honouring
132
+ * ADR-0009 execution pinning when a `process_hash` is recorded.
133
+ *
134
+ * Resolution order:
135
+ * 1. If `req.process_hash` AND `metadataRepo` are set, try
136
+ * `getByHash` — return a row whose `definition` is the pinned body.
137
+ * 2. Otherwise (or on lookup failure) fall back to the current
138
+ * projection via `getProcess(req.process_name)`.
139
+ */
140
+ private loadProcessForRequest;
108
141
  /** Mirror request status onto `process.approvalStatusField` if configured. */
109
142
  private syncStatusField;
110
143
  /** Convenience wrapper that funnels every action invocation through the executor. */
package/dist/index.js CHANGED
@@ -265,6 +265,7 @@ function rowFromRequest(row) {
265
265
  id: String(row.id),
266
266
  organization_id: row.organization_id ?? void 0,
267
267
  process_name: String(row.process_name ?? ""),
268
+ process_hash: row.process_hash ?? void 0,
268
269
  object_name: String(row.object_name ?? ""),
269
270
  record_id: String(row.record_id ?? ""),
270
271
  submitter_id: row.submitter_id ?? void 0,
@@ -299,6 +300,7 @@ var ApprovalService = class {
299
300
  this.fetchImpl = opts.fetch;
300
301
  this.webhookTimeoutMs = opts.webhookTimeoutMs;
301
302
  this.onRegistryChange = opts.onRegistryChange;
303
+ this.metadataRepo = opts.metadataRepo;
302
304
  }
303
305
  /** Allow the plugin to attach a hook re-binding callback after construction. */
304
306
  setRegistryChangeHandler(handler) {
@@ -467,6 +469,70 @@ var ApprovalService = class {
467
469
  this.logger?.warn?.("[approvals] onRegistryChange handler failed", { error: err?.message });
468
470
  }
469
471
  }
472
+ /**
473
+ * Look up the HEAD checksum of an approval process from the metadata repo
474
+ * (ADR-0009). Returns null when no repo is wired, no metadata exists for
475
+ * the name, or the lookup fails — callers MUST treat null as "do not pin"
476
+ * and fall back to the projection table.
477
+ */
478
+ async resolveProcessHash(processName, organizationId) {
479
+ if (!this.metadataRepo) return null;
480
+ if (!processName) return null;
481
+ const orgRef = { org: organizationId || "system", type: "approval", name: processName };
482
+ try {
483
+ const head = await this.metadataRepo.get(orgRef);
484
+ return head?.hash ?? null;
485
+ } catch (err) {
486
+ this.logger?.debug?.("[approvals] metadataRepo.get failed", { name: processName, error: err?.message });
487
+ return null;
488
+ }
489
+ }
490
+ /**
491
+ * Resolve the approval process for an in-flight request, honouring
492
+ * ADR-0009 execution pinning when a `process_hash` is recorded.
493
+ *
494
+ * Resolution order:
495
+ * 1. If `req.process_hash` AND `metadataRepo` are set, try
496
+ * `getByHash` — return a row whose `definition` is the pinned body.
497
+ * 2. Otherwise (or on lookup failure) fall back to the current
498
+ * projection via `getProcess(req.process_name)`.
499
+ */
500
+ async loadProcessForRequest(req, context) {
501
+ const hash = req.process_hash;
502
+ if (hash && this.metadataRepo) {
503
+ const orgId = req.organization_id ?? null;
504
+ const orgRef = { org: orgId || "system", type: "approval", name: req.process_name };
505
+ try {
506
+ const pinned = await this.metadataRepo.getByHash(orgRef, hash);
507
+ if (pinned?.body) {
508
+ const current = await this.getProcess(req.process_name, context);
509
+ const body = pinned.body;
510
+ return {
511
+ id: current?.id ?? `pinned_${hash.slice(7, 19)}`,
512
+ name: req.process_name,
513
+ label: body.label ?? current?.label ?? req.process_name,
514
+ object_name: req.object_name,
515
+ description: body.description ?? current?.description,
516
+ active: current?.active ?? true,
517
+ definition: body,
518
+ created_at: current?.created_at,
519
+ updated_at: current?.updated_at
520
+ };
521
+ }
522
+ this.logger?.warn?.("[approvals] pinned process body not found; falling back to current", {
523
+ request: req.id,
524
+ process: req.process_name,
525
+ hash
526
+ });
527
+ } catch (err) {
528
+ this.logger?.warn?.("[approvals] getByHash failed; falling back to current", {
529
+ request: req.id,
530
+ error: err?.message
531
+ });
532
+ }
533
+ }
534
+ return this.getProcess(req.process_name, context);
535
+ }
470
536
  /** Mirror request status onto `process.approvalStatusField` if configured. */
471
537
  async syncStatusField(process, request) {
472
538
  const field = process.definition?.approvalStatusField;
@@ -607,9 +673,11 @@ var ApprovalService = class {
607
673
  const approvers = await this.expandApprovers(step0, input.payload, ctxOrg);
608
674
  const now = this.clock.now().toISOString();
609
675
  const id = uid("areq");
676
+ const processHash = await this.resolveProcessHash(process.name, ctxOrg);
610
677
  const row = {
611
678
  id,
612
679
  process_name: process.name,
680
+ process_hash: processHash,
613
681
  object_name: input.object,
614
682
  record_id: input.recordId,
615
683
  submitter_id: input.submitterId ?? context.userId ?? null,
@@ -693,7 +761,7 @@ var ApprovalService = class {
693
761
  if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
694
762
  throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
695
763
  }
696
- const process = await this.getProcess(req.process_name, context);
764
+ const process = await this.loadProcessForRequest(req, context);
697
765
  if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
698
766
  const steps = process.definition?.steps ?? [];
699
767
  const stepIndex = req.current_step_index ?? 0;
@@ -765,7 +833,7 @@ var ApprovalService = class {
765
833
  if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
766
834
  throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
767
835
  }
768
- const process = await this.getProcess(req.process_name, context);
836
+ const process = await this.loadProcessForRequest(req, context);
769
837
  if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
770
838
  const steps = process.definition?.steps ?? [];
771
839
  const stepIndex = req.current_step_index ?? 0;
@@ -837,7 +905,7 @@ var ApprovalService = class {
837
905
  updated_at: now
838
906
  }, { context: SYSTEM_CTX2 });
839
907
  const fresh = await this.getRequest(req.id, context);
840
- const process = await this.getProcess(req.process_name, context);
908
+ const process = await this.loadProcessForRequest(req, context);
841
909
  if (process) {
842
910
  await this.syncStatusField(process, fresh);
843
911
  await this.runActions(process.definition?.onRecall, "recall", process, fresh, void 0, input.actorId, input.comment);
@@ -1047,10 +1115,20 @@ var ApprovalsServicePlugin = class {
1047
1115
  }
1048
1116
  this.engine = engine;
1049
1117
  this.logger = ctx.logger;
1118
+ let metadataRepo;
1119
+ try {
1120
+ const meta = ctx.getService("metadata");
1121
+ metadataRepo = meta?.getRepository?.();
1122
+ } catch {
1123
+ }
1050
1124
  this.service = new ApprovalService({
1051
1125
  engine,
1052
- logger: ctx.logger
1126
+ logger: ctx.logger,
1127
+ metadataRepo
1053
1128
  });
1129
+ if (metadataRepo) {
1130
+ ctx.logger.info("ApprovalsServicePlugin: execution pinning enabled (ADR-0009)");
1131
+ }
1054
1132
  if (!this.options.disableAutoHooks) {
1055
1133
  this.service.setRegistryChangeHandler(() => this.rebindHooks());
1056
1134
  const hookOn = ctx.hook ?? ctx.on;