@objectstack/plugin-approvals 5.1.0 → 6.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +56 -0
- package/dist/index.d.mts +33 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +82 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +82 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -5
- package/src/approval-service.test.ts +109 -0
- package/src/approval-service.ts +88 -3
- package/src/approvals-plugin.ts +14 -0
package/src/approval-service.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
SubmitApprovalInput,
|
|
14
14
|
SharingExecutionContext,
|
|
15
15
|
} from '@objectstack/spec/contracts';
|
|
16
|
+
import type { MetadataRepository } from '@objectstack/metadata-core';
|
|
16
17
|
import { executeActions, type ApprovalTrigger, type FetchLike } from './action-executor.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -69,6 +70,7 @@ function rowFromRequest(row: any): ApprovalRequestRow {
|
|
|
69
70
|
id: String(row.id),
|
|
70
71
|
organization_id: row.organization_id ?? undefined,
|
|
71
72
|
process_name: String(row.process_name ?? ''),
|
|
73
|
+
process_hash: row.process_hash ?? undefined,
|
|
72
74
|
object_name: String(row.object_name ?? ''),
|
|
73
75
|
record_id: String(row.record_id ?? ''),
|
|
74
76
|
submitter_id: row.submitter_id ?? undefined,
|
|
@@ -114,6 +116,19 @@ export interface ApprovalServiceOptions {
|
|
|
114
116
|
* The plugin uses this to re-bind lifecycle hooks for auto-trigger / lock.
|
|
115
117
|
*/
|
|
116
118
|
onRegistryChange?: () => void | Promise<void>;
|
|
119
|
+
/**
|
|
120
|
+
* Optional metadata repository for execution-pinned process resolution
|
|
121
|
+
* (ADR-0009). When provided:
|
|
122
|
+
*
|
|
123
|
+
* - `submit()` records the process body's sha256 on the request row.
|
|
124
|
+
* - `approve` / `reject` / `recall` resolve the pinned body via
|
|
125
|
+
* `MetadataRepository.getByHash` so process upgrades don't affect
|
|
126
|
+
* in-flight requests.
|
|
127
|
+
*
|
|
128
|
+
* When omitted, the service reads the current process from the
|
|
129
|
+
* `sys_approval_process` projection (pre-ADR-0009 behaviour).
|
|
130
|
+
*/
|
|
131
|
+
metadataRepo?: MetadataRepository;
|
|
117
132
|
}
|
|
118
133
|
|
|
119
134
|
export class ApprovalService implements IApprovalService {
|
|
@@ -123,6 +138,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
123
138
|
private readonly fetchImpl?: FetchLike;
|
|
124
139
|
private readonly webhookTimeoutMs?: number;
|
|
125
140
|
private readonly onRegistryChange?: () => void | Promise<void>;
|
|
141
|
+
private readonly metadataRepo?: MetadataRepository;
|
|
126
142
|
|
|
127
143
|
constructor(opts: ApprovalServiceOptions) {
|
|
128
144
|
this.engine = opts.engine;
|
|
@@ -131,6 +147,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
131
147
|
this.fetchImpl = opts.fetch;
|
|
132
148
|
this.webhookTimeoutMs = opts.webhookTimeoutMs;
|
|
133
149
|
this.onRegistryChange = opts.onRegistryChange;
|
|
150
|
+
this.metadataRepo = opts.metadataRepo;
|
|
134
151
|
}
|
|
135
152
|
|
|
136
153
|
/** Allow the plugin to attach a hook re-binding callback after construction. */
|
|
@@ -272,6 +289,72 @@ export class ApprovalService implements IApprovalService {
|
|
|
272
289
|
catch (err: any) { this.logger?.warn?.('[approvals] onRegistryChange handler failed', { error: err?.message }); }
|
|
273
290
|
}
|
|
274
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Look up the HEAD checksum of an approval process from the metadata repo
|
|
294
|
+
* (ADR-0009). Returns null when no repo is wired, no metadata exists for
|
|
295
|
+
* the name, or the lookup fails — callers MUST treat null as "do not pin"
|
|
296
|
+
* and fall back to the projection table.
|
|
297
|
+
*/
|
|
298
|
+
private async resolveProcessHash(processName: string, organizationId?: string | null): Promise<string | null> {
|
|
299
|
+
if (!this.metadataRepo) return null;
|
|
300
|
+
if (!processName) return null;
|
|
301
|
+
const orgRef = { org: organizationId || 'system', type: 'approval' as const, name: processName };
|
|
302
|
+
try {
|
|
303
|
+
const head = await this.metadataRepo.get(orgRef);
|
|
304
|
+
return head?.hash ?? null;
|
|
305
|
+
} catch (err: any) {
|
|
306
|
+
this.logger?.debug?.('[approvals] metadataRepo.get failed', { name: processName, error: err?.message });
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Resolve the approval process for an in-flight request, honouring
|
|
313
|
+
* ADR-0009 execution pinning when a `process_hash` is recorded.
|
|
314
|
+
*
|
|
315
|
+
* Resolution order:
|
|
316
|
+
* 1. If `req.process_hash` AND `metadataRepo` are set, try
|
|
317
|
+
* `getByHash` — return a row whose `definition` is the pinned body.
|
|
318
|
+
* 2. Otherwise (or on lookup failure) fall back to the current
|
|
319
|
+
* projection via `getProcess(req.process_name)`.
|
|
320
|
+
*/
|
|
321
|
+
private async loadProcessForRequest(req: ApprovalRequestRow, context: SharingExecutionContext): Promise<ApprovalProcessRow | null> {
|
|
322
|
+
const hash = req.process_hash;
|
|
323
|
+
if (hash && this.metadataRepo) {
|
|
324
|
+
const orgId = (req as any).organization_id ?? null;
|
|
325
|
+
const orgRef = { org: orgId || 'system', type: 'approval' as const, name: req.process_name };
|
|
326
|
+
try {
|
|
327
|
+
const pinned = await this.metadataRepo.getByHash(orgRef, hash);
|
|
328
|
+
if (pinned?.body) {
|
|
329
|
+
// Use the pinned body for the definition; pull identity/state
|
|
330
|
+
// fields from the current projection if available so display
|
|
331
|
+
// labels and active-flag stay fresh. Synthesize if absent.
|
|
332
|
+
const current = await this.getProcess(req.process_name, context);
|
|
333
|
+
const body: any = pinned.body;
|
|
334
|
+
return {
|
|
335
|
+
id: current?.id ?? `pinned_${hash.slice(7, 19)}`,
|
|
336
|
+
name: req.process_name,
|
|
337
|
+
label: body.label ?? current?.label ?? req.process_name,
|
|
338
|
+
object_name: req.object_name,
|
|
339
|
+
description: body.description ?? current?.description,
|
|
340
|
+
active: current?.active ?? true,
|
|
341
|
+
definition: body,
|
|
342
|
+
created_at: current?.created_at,
|
|
343
|
+
updated_at: current?.updated_at,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
this.logger?.warn?.('[approvals] pinned process body not found; falling back to current', {
|
|
347
|
+
request: req.id, process: req.process_name, hash,
|
|
348
|
+
});
|
|
349
|
+
} catch (err: any) {
|
|
350
|
+
this.logger?.warn?.('[approvals] getByHash failed; falling back to current', {
|
|
351
|
+
request: req.id, error: err?.message,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return this.getProcess(req.process_name, context);
|
|
356
|
+
}
|
|
357
|
+
|
|
275
358
|
/** Mirror request status onto `process.approvalStatusField` if configured. */
|
|
276
359
|
private async syncStatusField(process: ApprovalProcessRow, request: ApprovalRequestRow): Promise<void> {
|
|
277
360
|
const field = (process.definition as any)?.approvalStatusField;
|
|
@@ -432,9 +515,11 @@ export class ApprovalService implements IApprovalService {
|
|
|
432
515
|
|
|
433
516
|
const now = this.clock.now().toISOString();
|
|
434
517
|
const id = uid('areq');
|
|
518
|
+
const processHash = await this.resolveProcessHash(process.name, ctxOrg);
|
|
435
519
|
const row: any = {
|
|
436
520
|
id,
|
|
437
521
|
process_name: process.name,
|
|
522
|
+
process_hash: processHash,
|
|
438
523
|
object_name: input.object,
|
|
439
524
|
record_id: input.recordId,
|
|
440
525
|
submitter_id: input.submitterId ?? context.userId ?? null,
|
|
@@ -541,7 +626,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
541
626
|
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
542
627
|
}
|
|
543
628
|
|
|
544
|
-
const process = await this.
|
|
629
|
+
const process = await this.loadProcessForRequest(req, context);
|
|
545
630
|
if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
|
|
546
631
|
const steps: any[] = process.definition?.steps ?? [];
|
|
547
632
|
const stepIndex = req.current_step_index ?? 0;
|
|
@@ -624,7 +709,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
624
709
|
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
625
710
|
}
|
|
626
711
|
|
|
627
|
-
const process = await this.
|
|
712
|
+
const process = await this.loadProcessForRequest(req, context);
|
|
628
713
|
if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
|
|
629
714
|
const steps: any[] = process.definition?.steps ?? [];
|
|
630
715
|
const stepIndex = req.current_step_index ?? 0;
|
|
@@ -705,7 +790,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
705
790
|
}, { context: SYSTEM_CTX });
|
|
706
791
|
const fresh = await this.getRequest(req.id, context);
|
|
707
792
|
// Phase B: process.onRecall + status mirror.
|
|
708
|
-
const process = await this.
|
|
793
|
+
const process = await this.loadProcessForRequest(req, context);
|
|
709
794
|
if (process) {
|
|
710
795
|
await this.syncStatusField(process, fresh!);
|
|
711
796
|
await this.runActions((process.definition as any)?.onRecall, 'recall', process, fresh!, undefined, input.actorId, input.comment);
|
package/src/approvals-plugin.ts
CHANGED
|
@@ -67,11 +67,25 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
67
67
|
this.engine = engine;
|
|
68
68
|
this.logger = ctx.logger;
|
|
69
69
|
|
|
70
|
+
// ADR-0009: try to wire the metadata repository for execution pinning.
|
|
71
|
+
// The approvals service degrades to the projection-table path if no
|
|
72
|
+
// metadata service is registered (e.g. in tests or minimal setups).
|
|
73
|
+
let metadataRepo: any;
|
|
74
|
+
try {
|
|
75
|
+
const meta = ctx.getService<any>('metadata');
|
|
76
|
+
metadataRepo = meta?.getRepository?.();
|
|
77
|
+
} catch { /* metadata plugin not loaded — fall back */ }
|
|
78
|
+
|
|
70
79
|
this.service = new ApprovalService({
|
|
71
80
|
engine: engine as ApprovalEngine,
|
|
72
81
|
logger: ctx.logger,
|
|
82
|
+
metadataRepo,
|
|
73
83
|
});
|
|
74
84
|
|
|
85
|
+
if (metadataRepo) {
|
|
86
|
+
ctx.logger.info('ApprovalsServicePlugin: execution pinning enabled (ADR-0009)');
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
if (!this.options.disableAutoHooks) {
|
|
76
90
|
// Re-bind hooks on every registry mutation.
|
|
77
91
|
this.service.setRegistryChangeHandler(() => this.rebindHooks());
|