@veloxts/router 0.8.0 → 0.8.1

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
@@ -1,5 +1,14 @@
1
1
  # @veloxts/router
2
2
 
3
+ ## 0.8.1
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: add business logic primitives for B2B SaaS apps
8
+ - Updated dependencies
9
+ - @veloxts/core@0.8.1
10
+ - @veloxts/validation@0.8.1
11
+
3
12
  ## 0.8.0
4
13
 
5
14
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -39,14 +39,16 @@
39
39
  */
40
40
  /** Router package version */
41
41
  export declare const ROUTER_VERSION: string;
42
- export type { CompiledProcedure, ContextExtensions, ContextFactory, ExtendedContext, GuardLike, HttpMethod, InferProcedureContext, InferProcedureInput, InferProcedureOutput, Middleware, MiddlewareArgs, MiddlewareFunction, MiddlewareNext, MiddlewareResult, ParentResourceChain, ParentResourceConfig, ProcedureCollection, ProcedureHandler, ProcedureHandlerArgs, ProcedureRecord, ProcedureType, RestRouteOverride, } from './types.js';
42
+ export type { AfterHandler, CompiledProcedure, ContextExtensions, ContextFactory, ExtendedContext, GuardLike, HttpMethod, InferProcedureContext, InferProcedureErrors, InferProcedureInput, InferProcedureOutput, Middleware, MiddlewareArgs, MiddlewareFunction, MiddlewareNext, MiddlewareResult, ParentResourceChain, ParentResourceConfig, PolicyActionLike, ProcedureCollection, ProcedureHandler, ProcedureHandlerArgs, ProcedureRecord, ProcedureType, RestRouteOverride, TransactionalOptions, } from './types.js';
43
43
  export { PROCEDURE_METHOD_MAP, } from './types.js';
44
44
  export type { GuardErrorResponse, RouterErrorCode } from './errors.js';
45
45
  export { GuardError, isGuardError } from './errors.js';
46
46
  export type { DefineProceduresOptions, } from './procedure/builder.js';
47
47
  export { defineProcedures, executeProcedure, isCompiledProcedure, isProcedureCollection, procedure, procedures, } from './procedure/builder.js';
48
+ export type { PipelineStep, RevertAction, StepOptions } from './procedure/pipeline.js';
49
+ export { defineRevert, defineStep } from './procedure/pipeline.js';
48
50
  export { createProcedure, typedProcedure } from './procedure/factory.js';
49
- export type { BuilderRuntimeState, InferOutputSchema, InferProcedures, InferSchemaOutput, ProcedureBuilder, ProcedureBuilderState, ProcedureDefinitions, ValidOutputSchema, ValidSchema, } from './procedure/types.js';
51
+ export type { BuilderRuntimeState, InferOutputSchema, InferProcedures, InferSchemaOutput, PostHandlerBuilder, ProcedureBuilder, ProcedureBuilderState, ProcedureDefinitions, ValidOutputSchema, ValidSchema, } from './procedure/types.js';
50
52
  export type { RouterResult } from './router-utils.js';
51
53
  export { createRouter, toRouter } from './router-utils.js';
52
54
  export type { NamingWarning, NamingWarningType, WarningConfig, WarningOption } from './warnings.js';
package/dist/index.js CHANGED
@@ -51,6 +51,7 @@ export {
51
51
  // Builder functions
52
52
  defineProcedures, executeProcedure, isCompiledProcedure, isProcedureCollection, procedure, procedures, // Short alias for defineProcedures
53
53
  } from './procedure/builder.js';
54
+ export { defineRevert, defineStep } from './procedure/pipeline.js';
54
55
  // ============================================================================
55
56
  // Router Utilities
56
57
  // ============================================================================
@@ -41,7 +41,7 @@ import type { InferProcedures, ProcedureBuilder, ProcedureDefinitions } from './
41
41
  * });
42
42
  * ```
43
43
  */
44
- export declare function procedure<TContext extends BaseContext = BaseContext>(): ProcedureBuilder<unknown, unknown, TContext>;
44
+ export declare function procedure<TContext extends BaseContext = BaseContext>(): ProcedureBuilder<unknown, unknown, TContext, never>;
45
45
  /**
46
46
  * Options for defining a procedure collection
47
47
  */
@@ -7,12 +7,14 @@
7
7
  *
8
8
  * @module procedure/builder
9
9
  */
10
- import { ConfigurationError, logWarning } from '@veloxts/core';
10
+ import { ConfigurationError, createLogger, ForbiddenError, logWarning, } from '@veloxts/core';
11
11
  import { GuardError } from '../errors.js';
12
12
  import { createMiddlewareExecutor, executeMiddlewareChain } from '../middleware/chain.js';
13
13
  import { isResourceSchema, isTaggedResourceSchema, Resource, } from '../resource/index.js';
14
14
  import { deriveParentParamName } from '../utils/pluralization.js';
15
15
  import { analyzeNamingConvention, isDevelopment, normalizeWarningOption, } from '../warnings.js';
16
+ import { executeExternalSteps, executePipeline, splitPipelineSteps } from './pipeline-executor.js';
17
+ const log = createLogger('router');
16
18
  // ============================================================================
17
19
  // Builder Factory
18
20
  // ============================================================================
@@ -158,6 +160,24 @@ function createBuilder(state) {
158
160
  guards: [...state.guards, ...guardDefs],
159
161
  });
160
162
  },
163
+ /**
164
+ * Adds a policy action check to the procedure
165
+ */
166
+ policy(action) {
167
+ return createBuilder({
168
+ ...state,
169
+ policyAction: action,
170
+ });
171
+ },
172
+ /**
173
+ * Declares domain error classes this procedure may throw
174
+ */
175
+ throws(...errorClasses) {
176
+ return createBuilder({
177
+ ...state,
178
+ errorClasses: [...(state.errorClasses ?? []), ...errorClasses],
179
+ });
180
+ },
161
181
  /**
162
182
  * Sets REST route override
163
183
  */
@@ -213,6 +233,37 @@ function createBuilder(state) {
213
233
  parentResources: parentConfigs,
214
234
  });
215
235
  },
236
+ /**
237
+ * Declares a domain event to emit after successful handler execution
238
+ */
239
+ emits(eventClass, mapper) {
240
+ // Cast is safe: the typed TEventData and TOutput are erased at runtime.
241
+ // BuilderRuntimeState stores the widened types for uniform handling.
242
+ const entry = { eventClass, mapper };
243
+ return createBuilder({
244
+ ...state,
245
+ emittedEvents: [...(state.emittedEvents ?? []), entry],
246
+ });
247
+ },
248
+ /**
249
+ * Wraps the handler in a database transaction
250
+ */
251
+ transactional(options) {
252
+ return createBuilder({
253
+ ...state,
254
+ transactional: true,
255
+ transactionalOptions: options,
256
+ });
257
+ },
258
+ /**
259
+ * Adds pipeline steps that execute before the handler
260
+ */
261
+ through(...steps) {
262
+ return createBuilder({
263
+ ...state,
264
+ pipelineSteps: [...(state.pipelineSteps ?? []), ...steps],
265
+ });
266
+ },
216
267
  /**
217
268
  * Finalizes as a query procedure
218
269
  *
@@ -278,8 +329,8 @@ function compileProcedure(type, handler, state) {
278
329
  // Pre-compile the middleware chain executor if middlewares exist
279
330
  // This avoids rebuilding the chain on every request
280
331
  const precompiledExecutor = typedMiddlewares.length > 0 ? createMiddlewareExecutor(typedMiddlewares, handler) : undefined;
281
- // Create the final procedure object
282
- return {
332
+ // Create the final procedure object with .useAfter() support
333
+ return createPostHandlerBuilder({
283
334
  type,
284
335
  handler,
285
336
  inputSchema: state.inputSchema,
@@ -298,7 +349,18 @@ function compileProcedure(type, handler, state) {
298
349
  _resourceSchema: state.resourceSchema,
299
350
  // Store explicit resource level from tagged schema (e.g., UserSchema.authenticated)
300
351
  _resourceLevel: state.resourceLevel,
301
- };
352
+ // Store error classes declared via .throws()
353
+ errorClasses: state.errorClasses,
354
+ // Store transactional configuration
355
+ transactional: state.transactional,
356
+ transactionalOptions: state.transactionalOptions,
357
+ // Store emitted events declared via .emits()
358
+ emittedEvents: state.emittedEvents,
359
+ // Store pipeline steps declared via .through()
360
+ pipelineSteps: state.pipelineSteps,
361
+ // Store policy action declared via .policy()
362
+ policyAction: state.policyAction,
363
+ });
302
364
  }
303
365
  /**
304
366
  * Compiles a Level 3 branched procedure with a handler map
@@ -310,7 +372,7 @@ function compileProcedure(type, handler, state) {
310
372
  */
311
373
  function compileProcedureWithHandlerMap(type, dispatchHandler, handlerMap, state) {
312
374
  const typedMiddlewares = state.middlewares;
313
- return {
375
+ return createPostHandlerBuilder({
314
376
  type,
315
377
  handler: dispatchHandler,
316
378
  inputSchema: state.inputSchema,
@@ -327,6 +389,45 @@ function compileProcedureWithHandlerMap(type, dispatchHandler, handlerMap, state
327
389
  _resourceSchema: state.resourceSchema,
328
390
  _resourceLevel: undefined, // No fixed level — determined at runtime by branch selection
329
391
  _handlerMap: handlerMap,
392
+ // Store error classes declared via .throws()
393
+ errorClasses: state.errorClasses,
394
+ // Store transactional configuration
395
+ transactional: state.transactional,
396
+ transactionalOptions: state.transactionalOptions,
397
+ // Store pipeline steps declared via .through()
398
+ pipelineSteps: state.pipelineSteps,
399
+ // Store policy action declared via .policy()
400
+ policyAction: state.policyAction,
401
+ });
402
+ }
403
+ // ============================================================================
404
+ // PostHandlerBuilder Factory
405
+ // ============================================================================
406
+ /**
407
+ * Creates a PostHandlerBuilder from a CompiledProcedure's fields
408
+ *
409
+ * Wraps a compiled procedure object with a `.useAfter()` method that
410
+ * appends after-handler hooks. Each call returns a new PostHandlerBuilder
411
+ * with the hook added, preserving immutability.
412
+ *
413
+ * The resulting object passes `isCompiledProcedure` because it retains
414
+ * all required fields (type, handler, middlewares, guards).
415
+ *
416
+ * @internal
417
+ */
418
+ function createPostHandlerBuilder(compiled) {
419
+ return {
420
+ ...compiled,
421
+ useAfter(handler) {
422
+ const afterHandlers = [
423
+ ...(compiled.afterHandlers ?? []),
424
+ handler,
425
+ ];
426
+ return createPostHandlerBuilder({
427
+ ...compiled,
428
+ afterHandlers,
429
+ });
430
+ },
330
431
  };
331
432
  }
332
433
  /**
@@ -510,25 +611,101 @@ export async function executeProcedure(procedure, rawInput, ctx) {
510
611
  const input = procedure.inputSchema
511
612
  ? procedure.inputSchema.parse(rawInput)
512
613
  : rawInput;
614
+ // Step 2.3: Policy check — if .policy() was used
615
+ if (procedure.policyAction) {
616
+ const ctxRecord = ctxWithLevel;
617
+ const user = ctxRecord.user;
618
+ if (user == null) {
619
+ throw new ForbiddenError('Policy check failed: no authenticated user');
620
+ }
621
+ const resourceNameRaw = procedure.policyAction.resourceName;
622
+ const resourceName = resourceNameRaw.charAt(0).toLowerCase() + resourceNameRaw.slice(1);
623
+ const policyResource = ctxRecord[resourceName];
624
+ const allowed = await procedure.policyAction.check(user, policyResource);
625
+ if (!allowed) {
626
+ throw new ForbiddenError(`Policy check failed: cannot ${procedure.policyAction.actionName} ${procedure.policyAction.resourceName}`);
627
+ }
628
+ }
513
629
  // Step 2.5: Level 3 branch selection — if handler map is present
514
630
  if (procedure._handlerMap) {
515
631
  return executeBranchedProcedure(procedure, input, ctxWithLevel);
516
632
  }
633
+ // Step 2.7: Execute pipeline steps if .through() was used
634
+ // Pipeline transforms input before the handler. Runs inside transaction when applicable.
635
+ const pipelineSteps = procedure.pipelineSteps;
636
+ const hasPipeline = pipelineSteps !== undefined && pipelineSteps.length > 0;
517
637
  // Step 3: Execute handler (with or without middleware)
638
+ // Helper that runs a given set of pipeline steps + middleware chain + handler
639
+ const executeWithSteps = async (steps, execCtx) => {
640
+ const hasSteps = steps !== undefined && steps.length > 0;
641
+ // Run pipeline to transform input before handler
642
+ const handlerInput = hasSteps
643
+ ? (await executePipeline(steps, input, execCtx))
644
+ : input;
645
+ enrichedInput = handlerInput;
646
+ if (procedure._precompiledExecutor) {
647
+ // PERFORMANCE: Use pre-compiled middleware chain executor
648
+ return procedure._precompiledExecutor(handlerInput, execCtx);
649
+ }
650
+ if (procedure.middlewares.length === 0) {
651
+ // No middleware - execute handler directly
652
+ return procedure.handler({ input: handlerInput, ctx: execCtx });
653
+ }
654
+ // Fallback: Build middleware chain dynamically (should not normally happen)
655
+ return executeMiddlewareChain(procedure.middlewares, handlerInput, execCtx, async () => procedure.handler({ input: handlerInput, ctx: execCtx }));
656
+ };
518
657
  let result;
519
- if (procedure._precompiledExecutor) {
520
- // PERFORMANCE: Use pre-compiled middleware chain executor
521
- result = await procedure._precompiledExecutor(input, ctxWithLevel);
522
- }
523
- else if (procedure.middlewares.length === 0) {
524
- // No middleware - execute handler directly
525
- result = await procedure.handler({ input, ctx: ctxWithLevel });
658
+ let enrichedInput = input;
659
+ // Wrap in transaction if .transactional() was called and ctx.db.$transaction exists
660
+ const ctxRecord = ctxWithLevel;
661
+ const db = ctxRecord.db;
662
+ const isTransactional = procedure.transactional && db && typeof db.$transaction === 'function';
663
+ if (isTransactional) {
664
+ const $transaction = db.$transaction;
665
+ if (hasPipeline) {
666
+ // Two-phase model: split steps into DB (inside tx) and external (after commit)
667
+ const { dbSteps, externalSteps } = splitPipelineSteps(pipelineSteps);
668
+ // Phase A: Run DB steps + handler inside transaction
669
+ result = await $transaction(async (tx) => {
670
+ const txCtx = { ...ctxWithLevel, db: tx };
671
+ return executeWithSteps(dbSteps.length > 0 ? dbSteps : undefined, txCtx);
672
+ }, procedure.transactionalOptions);
673
+ // Phase B: Run external steps after commit (outside transaction)
674
+ if (externalSteps.length > 0) {
675
+ await executeExternalSteps(externalSteps, input, ctxWithLevel);
676
+ }
677
+ }
678
+ else {
679
+ // Transactional, no pipeline — wrap handler in $transaction (current behavior)
680
+ result = await $transaction(async (tx) => {
681
+ const txCtx = { ...ctxWithLevel, db: tx };
682
+ return executeWithSteps(undefined, txCtx);
683
+ }, procedure.transactionalOptions);
684
+ }
526
685
  }
527
686
  else {
528
- // Fallback: Build middleware chain dynamically (should not normally happen)
529
- result = await executeMiddlewareChain(procedure.middlewares, input, ctxWithLevel, async () => procedure.handler({ input, ctx: ctxWithLevel }));
687
+ // Not transactional run all steps in declaration order (regardless of external flag)
688
+ result = await executeWithSteps(hasPipeline ? pipelineSteps : undefined, ctxWithLevel);
530
689
  }
531
- // Step 4: Auto-project if resource schema is set
690
+ // Step 4: Emit domain events if .emits() was used
691
+ if (procedure.emittedEvents) {
692
+ const ctxEvents = ctxRecord
693
+ .events;
694
+ if (ctxEvents?.emit) {
695
+ for (const { eventClass, mapper } of procedure.emittedEvents) {
696
+ try {
697
+ const eventData = mapper
698
+ ? mapper(result)
699
+ : result;
700
+ await ctxEvents.emit(new eventClass(eventData));
701
+ }
702
+ catch (error) {
703
+ log.error('Event emission error:', error);
704
+ }
705
+ }
706
+ }
707
+ }
708
+ // Step 5: Auto-project if resource schema is set
532
709
  if (procedure._resourceSchema) {
533
710
  const schema = procedure._resourceSchema;
534
711
  // Prefer explicit level from tagged schema over guard-derived level
@@ -543,10 +720,22 @@ export async function executeProcedure(procedure, rawInput, ctx) {
543
720
  result = projectOne(result);
544
721
  }
545
722
  }
546
- // Step 5: Validate output if schema provided
723
+ // Step 6: Validate output if schema provided
547
724
  if (procedure.outputSchema) {
548
- return procedure.outputSchema.parse(result);
725
+ result = procedure.outputSchema.parse(result);
726
+ }
727
+ // Step 7: Execute after-handler hooks if .useAfter() was used
728
+ if (procedure.afterHandlers?.length) {
729
+ for (const afterHandler of procedure.afterHandlers) {
730
+ try {
731
+ await afterHandler({ input: enrichedInput, result, ctx: ctxWithLevel });
732
+ }
733
+ catch (error) {
734
+ log.error('useAfter hook error:', error);
735
+ }
736
+ }
549
737
  }
738
+ // Return the ORIGINAL result (hooks cannot modify it)
550
739
  return result;
551
740
  }
552
741
  // ============================================================================
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pipeline executor for .through() steps
3
+ *
4
+ * Executes pipeline steps in declaration order. Each step's output becomes
5
+ * the next step's input. On failure, runs revert actions for completed
6
+ * steps in reverse order (compensation pattern).
7
+ *
8
+ * When `.transactional()` is combined with `.through()`, the executor
9
+ * implements a two-phase model:
10
+ * - Phase A: DB steps (non-external) run inside the transaction
11
+ * - Phase B: External steps run after the transaction commits
12
+ *
13
+ * @module procedure/pipeline-executor
14
+ */
15
+ import type { BaseContext } from '@veloxts/core';
16
+ import type { PipelineStep } from './pipeline.js';
17
+ /**
18
+ * Result of splitting pipeline steps into DB and external phases
19
+ */
20
+ export interface SplitPipelineSteps {
21
+ /** Steps that run inside the DB transaction (external === false) */
22
+ readonly dbSteps: ReadonlyArray<PipelineStep>;
23
+ /** Steps that run after transaction commit (external === true) */
24
+ readonly externalSteps: ReadonlyArray<PipelineStep>;
25
+ }
26
+ /**
27
+ * Splits pipeline steps into DB (non-external) and external phases
28
+ *
29
+ * Validates the ordering constraint: when transactional, all external steps
30
+ * must come AFTER all DB steps. If an external step appears before a DB step,
31
+ * a ConfigurationError is thrown.
32
+ *
33
+ * @param steps - Pipeline steps to split
34
+ * @returns Object with `dbSteps` and `externalSteps` arrays
35
+ * @throws ConfigurationError if an external step precedes a DB step
36
+ */
37
+ export declare function splitPipelineSteps(steps: ReadonlyArray<PipelineStep>): SplitPipelineSteps;
38
+ /**
39
+ * Executes a sequence of pipeline steps
40
+ *
41
+ * Steps run in declaration order. Each step receives the previous step's
42
+ * output as its input (the first step receives the original validated input).
43
+ *
44
+ * If a step fails:
45
+ * 1. Collects all completed steps that have a revertAction
46
+ * 2. Runs their revert handlers in REVERSE order
47
+ * 3. Each revert receives the output of the step being reverted
48
+ * 4. Revert errors are logged but do not suppress the original error
49
+ * 5. Rethrows the original error
50
+ *
51
+ * @param steps - Pipeline steps to execute in order
52
+ * @param input - Initial input (typically the validated procedure input)
53
+ * @param ctx - Request context
54
+ * @returns The output of the last step
55
+ */
56
+ export declare function executePipeline(steps: ReadonlyArray<PipelineStep>, input: unknown, ctx: BaseContext): Promise<unknown>;
57
+ /**
58
+ * Executes external pipeline steps after a transaction has committed
59
+ *
60
+ * External steps run in declaration order. If a step fails, revert actions
61
+ * are run for all previously completed EXTERNAL steps in reverse order.
62
+ * The DB transaction is already committed — DB changes persist.
63
+ *
64
+ * @param steps - External pipeline steps to execute in order
65
+ * @param input - Input for the first external step (output of last DB step, or original input)
66
+ * @param ctx - Request context (with the ORIGINAL db, not the transactional client)
67
+ * @returns The output of the last external step (ignored by the caller, since handler already ran)
68
+ */
69
+ export declare function executeExternalSteps(steps: ReadonlyArray<PipelineStep>, input: unknown, ctx: BaseContext): Promise<void>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Pipeline executor for .through() steps
3
+ *
4
+ * Executes pipeline steps in declaration order. Each step's output becomes
5
+ * the next step's input. On failure, runs revert actions for completed
6
+ * steps in reverse order (compensation pattern).
7
+ *
8
+ * When `.transactional()` is combined with `.through()`, the executor
9
+ * implements a two-phase model:
10
+ * - Phase A: DB steps (non-external) run inside the transaction
11
+ * - Phase B: External steps run after the transaction commits
12
+ *
13
+ * @module procedure/pipeline-executor
14
+ */
15
+ import { ConfigurationError, createLogger } from '@veloxts/core';
16
+ const log = createLogger('router');
17
+ /**
18
+ * Splits pipeline steps into DB (non-external) and external phases
19
+ *
20
+ * Validates the ordering constraint: when transactional, all external steps
21
+ * must come AFTER all DB steps. If an external step appears before a DB step,
22
+ * a ConfigurationError is thrown.
23
+ *
24
+ * @param steps - Pipeline steps to split
25
+ * @returns Object with `dbSteps` and `externalSteps` arrays
26
+ * @throws ConfigurationError if an external step precedes a DB step
27
+ */
28
+ export function splitPipelineSteps(steps) {
29
+ const dbSteps = [];
30
+ const externalSteps = [];
31
+ let seenExternal = false;
32
+ for (const step of steps) {
33
+ if (step.external) {
34
+ seenExternal = true;
35
+ externalSteps.push(step);
36
+ }
37
+ else {
38
+ if (seenExternal) {
39
+ throw new ConfigurationError(`Pipeline step "${step.name}" (DB) is declared after external step "${externalSteps[externalSteps.length - 1].name}". ` +
40
+ 'When using .transactional(), all external steps must come after all DB steps in the .through() declaration.');
41
+ }
42
+ dbSteps.push(step);
43
+ }
44
+ }
45
+ return { dbSteps, externalSteps };
46
+ }
47
+ /**
48
+ * Executes a sequence of pipeline steps
49
+ *
50
+ * Steps run in declaration order. Each step receives the previous step's
51
+ * output as its input (the first step receives the original validated input).
52
+ *
53
+ * If a step fails:
54
+ * 1. Collects all completed steps that have a revertAction
55
+ * 2. Runs their revert handlers in REVERSE order
56
+ * 3. Each revert receives the output of the step being reverted
57
+ * 4. Revert errors are logged but do not suppress the original error
58
+ * 5. Rethrows the original error
59
+ *
60
+ * @param steps - Pipeline steps to execute in order
61
+ * @param input - Initial input (typically the validated procedure input)
62
+ * @param ctx - Request context
63
+ * @returns The output of the last step
64
+ */
65
+ export async function executePipeline(steps, input, ctx) {
66
+ const completedSteps = [];
67
+ let currentInput = input;
68
+ for (const step of steps) {
69
+ try {
70
+ const output = await step.handler({ input: currentInput, ctx });
71
+ completedSteps.push({ step, output });
72
+ currentInput = output;
73
+ }
74
+ catch (error) {
75
+ // Step failed — run reverts for completed steps in reverse order
76
+ await runReverts(completedSteps, ctx);
77
+ throw error;
78
+ }
79
+ }
80
+ return currentInput;
81
+ }
82
+ /**
83
+ * Executes external pipeline steps after a transaction has committed
84
+ *
85
+ * External steps run in declaration order. If a step fails, revert actions
86
+ * are run for all previously completed EXTERNAL steps in reverse order.
87
+ * The DB transaction is already committed — DB changes persist.
88
+ *
89
+ * @param steps - External pipeline steps to execute in order
90
+ * @param input - Input for the first external step (output of last DB step, or original input)
91
+ * @param ctx - Request context (with the ORIGINAL db, not the transactional client)
92
+ * @returns The output of the last external step (ignored by the caller, since handler already ran)
93
+ */
94
+ export async function executeExternalSteps(steps, input, ctx) {
95
+ const completedSteps = [];
96
+ let currentInput = input;
97
+ for (const step of steps) {
98
+ try {
99
+ const output = await step.handler({ input: currentInput, ctx });
100
+ completedSteps.push({ step, output });
101
+ currentInput = output;
102
+ }
103
+ catch (error) {
104
+ // External step failed — revert only completed external steps
105
+ await runReverts(completedSteps, ctx);
106
+ throw error;
107
+ }
108
+ }
109
+ }
110
+ /**
111
+ * Runs revert actions for completed steps in reverse order
112
+ *
113
+ * Each revert receives the output of the step it is reverting.
114
+ * Revert errors are logged but never suppress the original error.
115
+ *
116
+ * @param completedSteps - Steps that completed successfully before the failure
117
+ * @param ctx - Request context (forwarded to revert handlers)
118
+ * @internal
119
+ */
120
+ async function runReverts(completedSteps, ctx) {
121
+ // Process in reverse order
122
+ for (let i = completedSteps.length - 1; i >= 0; i--) {
123
+ const { step, output } = completedSteps[i];
124
+ if (!step.revertAction) {
125
+ continue;
126
+ }
127
+ try {
128
+ await step.revertAction.handler({ input: output, ctx });
129
+ }
130
+ catch (revertError) {
131
+ log.error(`Revert "${step.revertAction.name}" for step "${step.name}" failed:`, revertError);
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Pipeline step and revert action factories
3
+ *
4
+ * Provides `defineStep` and `defineRevert` for building pipeline steps
5
+ * that execute in sequence via `.through()` on the procedure builder.
6
+ * Each step's output becomes the next step's input. External steps run
7
+ * outside DB transactions and can have revert actions for compensation.
8
+ *
9
+ * @module procedure/pipeline
10
+ */
11
+ import type { BaseContext } from '@veloxts/core';
12
+ /**
13
+ * Options for configuring a pipeline step
14
+ */
15
+ export interface StepOptions {
16
+ /** Step name used for logging and error reporting */
17
+ readonly name: string;
18
+ /** Whether this step runs outside the DB transaction (default: false) */
19
+ readonly external?: boolean;
20
+ }
21
+ /**
22
+ * A revert action that undoes the effect of an external step
23
+ *
24
+ * Revert actions are invoked when a later step in the pipeline fails,
25
+ * allowing compensation for steps that ran outside the DB transaction.
26
+ */
27
+ export interface RevertAction<TInput = unknown> {
28
+ readonly name: string;
29
+ readonly handler: (params: {
30
+ input: TInput;
31
+ ctx: BaseContext;
32
+ }) => void | Promise<void>;
33
+ }
34
+ /**
35
+ * A single step in a procedure pipeline
36
+ *
37
+ * Steps execute in order, each one's output becoming the next one's input.
38
+ * External steps run outside DB transactions and may have revert actions
39
+ * for compensation when a later step fails.
40
+ */
41
+ export interface PipelineStep<TInput = unknown, TOutput = unknown> {
42
+ readonly name: string;
43
+ readonly external: boolean;
44
+ readonly handler: (params: {
45
+ input: TInput;
46
+ ctx: BaseContext;
47
+ }) => TOutput | Promise<TOutput>;
48
+ readonly revertAction?: RevertAction;
49
+ /** Attach a revert action to this step, returning a new step (immutable) */
50
+ onRevert(revert: RevertAction): PipelineStep<TInput, TOutput>;
51
+ }
52
+ /** Handler function signature for pipeline steps */
53
+ type StepHandler<TInput, TOutput> = (params: {
54
+ input: TInput;
55
+ ctx: BaseContext;
56
+ }) => TOutput | Promise<TOutput>;
57
+ /**
58
+ * Define a pipeline step with a string name (external defaults to false)
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * const validate = defineStep('validateInventory', async ({ input, ctx }) => {
63
+ * return { ...input, validated: true };
64
+ * });
65
+ * ```
66
+ */
67
+ export declare function defineStep<TInput = unknown, TOutput = unknown>(name: string, handler: StepHandler<TInput, TOutput>): PipelineStep<TInput, TOutput>;
68
+ /**
69
+ * Define a pipeline step with options (name + external flag)
70
+ *
71
+ * @example
72
+ * ```typescript
73
+ * const charge = defineStep(
74
+ * { name: 'chargePayment', external: true },
75
+ * async ({ input, ctx }) => {
76
+ * return { ...input, chargeId: 'ch_123' };
77
+ * },
78
+ * );
79
+ * ```
80
+ */
81
+ export declare function defineStep<TInput = unknown, TOutput = unknown>(options: StepOptions, handler: StepHandler<TInput, TOutput>): PipelineStep<TInput, TOutput>;
82
+ /**
83
+ * Define a revert action for compensating an external pipeline step
84
+ *
85
+ * Revert handlers receive the same `{ input, ctx }` shape but return void.
86
+ *
87
+ * @example
88
+ * ```typescript
89
+ * const refund = defineRevert('refundPayment', async ({ input, ctx }) => {
90
+ * await gateway.refund(input.chargeId);
91
+ * });
92
+ * ```
93
+ */
94
+ export declare function defineRevert<TInput = unknown>(name: string, handler: (params: {
95
+ input: TInput;
96
+ ctx: BaseContext;
97
+ }) => void | Promise<void>): RevertAction<TInput>;
98
+ export {};