@nest-batch/core 0.2.0 → 0.2.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.
Files changed (85) hide show
  1. package/README.md +5 -3
  2. package/dist/src/adapters/in-process.adapter.d.ts +16 -13
  3. package/dist/src/adapters/in-process.adapter.d.ts.map +1 -1
  4. package/dist/src/adapters/in-process.adapter.js +2 -0
  5. package/dist/src/adapters/in-process.adapter.js.map +1 -1
  6. package/dist/src/compiler/definition-compiler.d.ts.map +1 -1
  7. package/dist/src/compiler/definition-compiler.js +3 -0
  8. package/dist/src/compiler/definition-compiler.js.map +1 -1
  9. package/dist/src/core/ir/listener-definition.d.ts +2 -0
  10. package/dist/src/core/ir/listener-definition.d.ts.map +1 -1
  11. package/dist/src/core/item/interfaces.d.ts +14 -4
  12. package/dist/src/core/item/interfaces.d.ts.map +1 -1
  13. package/dist/src/decorators/listener.decorators.d.ts +4 -3
  14. package/dist/src/decorators/listener.decorators.d.ts.map +1 -1
  15. package/dist/src/decorators/listener.decorators.js +6 -3
  16. package/dist/src/decorators/listener.decorators.js.map +1 -1
  17. package/dist/src/execution/chunk-step-executor.d.ts +7 -1
  18. package/dist/src/execution/chunk-step-executor.d.ts.map +1 -1
  19. package/dist/src/execution/chunk-step-executor.js +104 -13
  20. package/dist/src/execution/chunk-step-executor.js.map +1 -1
  21. package/dist/src/execution/in-process-schedule.d.ts +25 -0
  22. package/dist/src/execution/in-process-schedule.d.ts.map +1 -0
  23. package/dist/src/execution/in-process-schedule.js +129 -0
  24. package/dist/src/execution/in-process-schedule.js.map +1 -0
  25. package/dist/src/execution/index.d.ts +1 -0
  26. package/dist/src/execution/index.d.ts.map +1 -1
  27. package/dist/src/execution/index.js +1 -0
  28. package/dist/src/execution/index.js.map +1 -1
  29. package/dist/src/execution/job-executor.d.ts.map +1 -1
  30. package/dist/src/execution/job-executor.js +14 -8
  31. package/dist/src/execution/job-executor.js.map +1 -1
  32. package/dist/src/execution/listener-invoker.d.ts +25 -9
  33. package/dist/src/execution/listener-invoker.d.ts.map +1 -1
  34. package/dist/src/execution/listener-invoker.js +70 -14
  35. package/dist/src/execution/listener-invoker.js.map +1 -1
  36. package/dist/src/execution/tasklet-step-executor.d.ts +4 -1
  37. package/dist/src/execution/tasklet-step-executor.d.ts.map +1 -1
  38. package/dist/src/execution/tasklet-step-executor.js +20 -16
  39. package/dist/src/execution/tasklet-step-executor.js.map +1 -1
  40. package/dist/src/explorer/batch-explorer.d.ts +2 -1
  41. package/dist/src/explorer/batch-explorer.d.ts.map +1 -1
  42. package/dist/src/explorer/batch-explorer.js +3 -0
  43. package/dist/src/explorer/batch-explorer.js.map +1 -1
  44. package/dist/src/index.d.ts +1 -0
  45. package/dist/src/index.d.ts.map +1 -1
  46. package/dist/src/index.js +1 -0
  47. package/dist/src/index.js.map +1 -1
  48. package/dist/src/module/batch-schedule-registry.d.ts +13 -14
  49. package/dist/src/module/batch-schedule-registry.d.ts.map +1 -1
  50. package/dist/src/module/batch-schedule-registry.js +0 -0
  51. package/dist/src/module/batch-schedule-registry.js.map +1 -1
  52. package/dist/src/module/nest-batch.module.d.ts +4 -3
  53. package/dist/src/module/nest-batch.module.d.ts.map +1 -1
  54. package/dist/src/module/nest-batch.module.js +3 -2
  55. package/dist/src/module/nest-batch.module.js.map +1 -1
  56. package/dist/src/module/tokens.d.ts +5 -6
  57. package/dist/src/module/tokens.d.ts.map +1 -1
  58. package/dist/src/module/tokens.js.map +1 -1
  59. package/dist/src/partition-helpers.d.ts +3 -3
  60. package/dist/src/partition-helpers.d.ts.map +1 -1
  61. package/dist/src/partition-helpers.js +3 -3
  62. package/dist/src/partition-helpers.js.map +1 -1
  63. package/dist/src/scheduling/batch-scheduled.d.ts +9 -11
  64. package/dist/src/scheduling/batch-scheduled.d.ts.map +1 -1
  65. package/dist/src/scheduling/batch-scheduled.js +12 -20
  66. package/dist/src/scheduling/batch-scheduled.js.map +1 -1
  67. package/package.json +4 -1
  68. package/src/adapters/in-process.adapter.ts +18 -13
  69. package/src/compiler/definition-compiler.ts +12 -5
  70. package/src/core/ir/listener-definition.ts +2 -0
  71. package/src/core/item/interfaces.ts +15 -4
  72. package/src/decorators/listener.decorators.ts +11 -13
  73. package/src/execution/chunk-step-executor.ts +212 -18
  74. package/src/execution/in-process-schedule.ts +143 -0
  75. package/src/execution/index.ts +1 -0
  76. package/src/execution/job-executor.ts +30 -21
  77. package/src/execution/listener-invoker.ts +105 -27
  78. package/src/execution/tasklet-step-executor.ts +40 -16
  79. package/src/explorer/batch-explorer.ts +10 -4
  80. package/src/index.ts +1 -0
  81. package/src/module/batch-schedule-registry.ts +0 -0
  82. package/src/module/nest-batch.module.ts +21 -42
  83. package/src/module/tokens.ts +8 -15
  84. package/src/partition-helpers.ts +13 -17
  85. package/src/scheduling/batch-scheduled.ts +22 -32
@@ -86,6 +86,7 @@ export class DefinitionCompiler {
86
86
  const listenerDefs: ListenerDefinition[] = discovered.listenerMethods.map((l) => ({
87
87
  kind: l.kind,
88
88
  phase: l.phase,
89
+ ...(l.skipKind !== undefined ? { skipKind: l.skipKind } : {}),
89
90
  nonCritical: l.nonCritical,
90
91
  ref: this.buildListenerRef(discovered, classToken, l.methodName),
91
92
  }));
@@ -252,7 +253,11 @@ export class DefinitionCompiler {
252
253
  throw new InvalidFlowGraphError(
253
254
  'INVALID_PARTITIONS',
254
255
  `Step "${step.options.id}" has invalid partitions: ${err.message}`,
255
- { jobId: discovered.jobOptions.id, stepId: step.options.id, partitions: step.options.partitions },
256
+ {
257
+ jobId: discovered.jobOptions.id,
258
+ stepId: step.options.id,
259
+ partitions: step.options.partitions,
260
+ },
256
261
  );
257
262
  }
258
263
  throw err;
@@ -267,12 +272,14 @@ export class DefinitionCompiler {
267
272
  skipPolicy: step.options.skipPolicy,
268
273
  retryPolicy: step.options.retryPolicy,
269
274
  listeners: [],
270
- ...(step.options.partitions !== undefined
271
- ? { partitions: step.options.partitions }
272
- : {}),
275
+ ...(step.options.partitions !== undefined ? { partitions: step.options.partitions } : {}),
273
276
  ...(processor
274
277
  ? {
275
- processor: this.buildItemMethodRef(discovered, classToken, processor) satisfies ProcessorRef,
278
+ processor: this.buildItemMethodRef(
279
+ discovered,
280
+ classToken,
281
+ processor,
282
+ ) satisfies ProcessorRef,
276
283
  }
277
284
  : {}),
278
285
  };
@@ -10,10 +10,12 @@ export type ListenerKind =
10
10
  | 'skip'
11
11
  | 'transition';
12
12
  export type ListenerPhase = 'before' | 'after' | 'on-error';
13
+ export type SkipSubKind = 'read' | 'process' | 'write';
13
14
 
14
15
  export interface ListenerDefinition {
15
16
  kind: ListenerKind;
16
17
  ref: ListenerRef;
17
18
  phase: ListenerPhase;
19
+ skipKind?: SkipSubKind;
18
20
  nonCritical?: boolean;
19
21
  }
@@ -1,4 +1,4 @@
1
- import type { ExecutionContext, ExecutionScope } from '../repository/types';
1
+ import type { ExecutionContext, ExecutionScope, JobParameters } from '../repository/types';
2
2
 
3
3
  type MaybePromise<T> = T | Promise<T>;
4
4
 
@@ -6,8 +6,18 @@ type MaybePromise<T> = T | Promise<T>;
6
6
  * Reads one item at a time. Returns `null` to signal EOF.
7
7
  * For async iteration, use `AsyncIterable.read()` (not yet supported in MVP).
8
8
  */
9
+ export interface ItemExecutionContext {
10
+ readonly jobExecutionId: string;
11
+ readonly stepExecutionId: string;
12
+ readonly stepName: string;
13
+ readonly jobParameters: JobParameters;
14
+ readonly chunkIndex?: number;
15
+ getExecutionContext(): Promise<ExecutionContext>;
16
+ saveExecutionContext(ctx: ExecutionContext): Promise<void>;
17
+ }
18
+
9
19
  export interface ItemReader<T = unknown> {
10
- read(): Promise<T | null>;
20
+ read(ctx?: ItemExecutionContext): Promise<T | null>;
11
21
  }
12
22
 
13
23
  /**
@@ -15,7 +25,7 @@ export interface ItemReader<T = unknown> {
15
25
  * Throws to indicate the item should be skipped (via SkipPolicy) or retried (via RetryPolicy).
16
26
  */
17
27
  export interface ItemProcessor<I = unknown, O = unknown> {
18
- process(item: I): Promise<O | null | undefined>;
28
+ process(item: I, ctx?: ItemExecutionContext): Promise<O | null | undefined>;
19
29
  }
20
30
 
21
31
  /**
@@ -28,7 +38,7 @@ export interface ItemProcessor<I = unknown, O = unknown> {
28
38
  * count and assumes no per-item skips.
29
39
  */
30
40
  export interface ItemWriter<T = unknown> {
31
- write(items: T[]): Promise<WriterResult | void>;
41
+ write(items: T[], ctx?: ItemExecutionContext): Promise<WriterResult | void>;
32
42
  }
33
43
 
34
44
  export interface WriterResult {
@@ -57,6 +67,7 @@ export interface ItemStream {
57
67
  export interface TaskletContext {
58
68
  readonly jobExecutionId: string;
59
69
  readonly stepExecutionId: string;
70
+ readonly jobParameters: JobParameters;
60
71
  getExecutionContext(): Promise<ExecutionContext>;
61
72
  saveExecutionContext(ctx: ExecutionContext): Promise<void>;
62
73
  }
@@ -1,6 +1,6 @@
1
1
  import 'reflect-metadata';
2
2
  import { BATCH_LISTENER_METADATA } from './constants';
3
- import type { ListenerKind, ListenerPhase } from '../core/ir/listener-definition';
3
+ import type { ListenerKind, ListenerPhase, SkipSubKind } from '../core/ir/listener-definition';
4
4
 
5
5
  /**
6
6
  * Stored under `BATCH_LISTENER_METADATA` for each listener method.
@@ -9,12 +9,13 @@ import type { ListenerKind, ListenerPhase } from '../core/ir/listener-definition
9
9
  *
10
10
  * The `skip` kind is special: it has no before/after/on-error phase,
11
11
  * it is a single fire-and-forget callback per skip event. We record
12
- * `phase: 'after'` as a placeholder so the metadata shape stays uniform;
13
- * the skip dispatch table is built in Task 25.
12
+ * `phase: 'after'` as a placeholder so the metadata shape stays uniform,
13
+ * plus `skipKind` so read/process/write skip events remain distinguishable.
14
14
  */
15
15
  export interface ListenerOptions {
16
16
  kind: ListenerKind;
17
17
  phase: ListenerPhase;
18
+ skipKind?: SkipSubKind;
18
19
  nonCritical?: boolean;
19
20
  }
20
21
 
@@ -37,12 +38,10 @@ function listenerDecorator(options: ListenerOptions): MethodDecorator {
37
38
  // ---------------------------------------------------------------------------
38
39
 
39
40
  /** Fires before a job execution starts. */
40
- export const BeforeJob = (): MethodDecorator =>
41
- listenerDecorator({ kind: 'job', phase: 'before' });
41
+ export const BeforeJob = (): MethodDecorator => listenerDecorator({ kind: 'job', phase: 'before' });
42
42
 
43
43
  /** Fires after a job execution finishes (regardless of status). */
44
- export const AfterJob = (): MethodDecorator =>
45
- listenerDecorator({ kind: 'job', phase: 'after' });
44
+ export const AfterJob = (): MethodDecorator => listenerDecorator({ kind: 'job', phase: 'after' });
46
45
 
47
46
  // ---------------------------------------------------------------------------
48
47
  // Step-level listeners (2)
@@ -53,8 +52,7 @@ export const BeforeStep = (): MethodDecorator =>
53
52
  listenerDecorator({ kind: 'step', phase: 'before' });
54
53
 
55
54
  /** Fires after a step execution finishes (regardless of status). */
56
- export const AfterStep = (): MethodDecorator =>
57
- listenerDecorator({ kind: 'step', phase: 'after' });
55
+ export const AfterStep = (): MethodDecorator => listenerDecorator({ kind: 'step', phase: 'after' });
58
56
 
59
57
  // ---------------------------------------------------------------------------
60
58
  // Chunk-level listeners (3)
@@ -126,17 +124,17 @@ export const OnWriteError = (): MethodDecorator =>
126
124
  // Skip listeners are not phase-based — each kind handles a distinct skip
127
125
  // event emitted by the corresponding read/process/write. We store them
128
126
  // under `kind: 'skip'` with `phase: 'after'` as a placeholder so the
129
- // metadata shape is uniform; the dispatch table is built in Task 25.
127
+ // metadata shape is uniform, plus `skipKind` for runtime dispatch.
130
128
  // ---------------------------------------------------------------------------
131
129
 
132
130
  /** Fires when a read is skipped (after the skip policy decides to skip). */
133
131
  export const OnSkipRead = (): MethodDecorator =>
134
- listenerDecorator({ kind: 'skip', phase: 'after' });
132
+ listenerDecorator({ kind: 'skip', phase: 'after', skipKind: 'read' });
135
133
 
136
134
  /** Fires when a processed item is skipped. */
137
135
  export const OnSkipProcess = (): MethodDecorator =>
138
- listenerDecorator({ kind: 'skip', phase: 'after' });
136
+ listenerDecorator({ kind: 'skip', phase: 'after', skipKind: 'process' });
139
137
 
140
138
  /** Fires when a write is skipped. */
141
139
  export const OnSkipWrite = (): MethodDecorator =>
142
- listenerDecorator({ kind: 'skip', phase: 'after' });
140
+ listenerDecorator({ kind: 'skip', phase: 'after', skipKind: 'write' });
@@ -1,8 +1,14 @@
1
1
  import { Injectable } from '@nestjs/common';
2
2
  import type { ChunkStepDefinition, ReaderRef, ProcessorRef, WriterRef } from '../core/ir';
3
3
  import { RefKind } from '../core/ir';
4
- import type { ItemReader, ItemProcessor, ItemStream, ItemWriter } from '../core/item';
5
- import type { ExecutionContext, JobRepository } from '../core/repository';
4
+ import type {
5
+ ItemExecutionContext,
6
+ ItemReader,
7
+ ItemProcessor,
8
+ ItemStream,
9
+ ItemWriter,
10
+ } from '../core/item';
11
+ import type { ExecutionContext, JobParameters, JobRepository } from '../core/repository';
6
12
  import type { TransactionManager } from '../core/transaction';
7
13
  import { StepStatus } from '../core/status';
8
14
  import type { SkipPolicy, SkipContext } from '../policies/skip-policy';
@@ -20,9 +26,13 @@ export interface ChunkExecutionContext {
20
26
  /** Step execution id, used to scope the chunk-progress checkpoint in the
21
27
  * step's ExecutionContext (saved as `{ lastChunkIndex }`). */
22
28
  stepExecutionId: string;
29
+ stepName?: string;
30
+ jobParameters?: JobParameters;
23
31
  jobRepository: JobRepository;
24
32
  transactionManager: TransactionManager;
25
33
  listenerInvoker: ListenerInvoker;
34
+ /** Full listener resolver map keyed by `${phase}:${kind}:${name}`. */
35
+ listenerResolvers?: ResolverMap;
26
36
  /** Map of resolved reader/processor/writer functions by name. */
27
37
  resolvers: Map<string, (...args: unknown[]) => unknown | Promise<unknown>>;
28
38
  jobExecutionId2: string; // unique key for listener resolver namespacing
@@ -90,7 +100,7 @@ type PhaseResult<T> = { kind: 'ok'; value: T } | { kind: 'skipped' };
90
100
  * live `skipCount` accumulator so the policy's budget check is consistent
91
101
  * with the accounting in the outer loop.
92
102
  */
93
- interface RunPhaseOptions {
103
+ interface RunPhaseOptions<T = unknown> {
94
104
  phase: Phase;
95
105
  item: unknown;
96
106
  skipPolicy: SkipPolicy | null;
@@ -102,6 +112,9 @@ interface RunPhaseOptions {
102
112
  /** Invoked when an error is actually skipped (within budget). The caller
103
113
  * is responsible for incrementing its own skipCount here. */
104
114
  onSkip: (err: unknown) => Promise<void>;
115
+ before?: () => Promise<void>;
116
+ after?: (value: T) => Promise<void>;
117
+ onError?: (err: unknown) => Promise<void>;
105
118
  }
106
119
 
107
120
  function isItemStream(value: unknown): value is ItemStream {
@@ -135,7 +148,8 @@ export class ChunkStepExecutor {
135
148
  ): Promise<ChunkExecutionResult> {
136
149
  const skipPolicy = step.skipPolicy ? compileSkipPolicy(step.skipPolicy) : null;
137
150
  const retryPolicy = step.retryPolicy ? compileRetryPolicy(step.retryPolicy) : null;
138
- const skipResolvers: ResolverMap = context.skipListenerResolvers ?? new Map();
151
+ const listenerResolvers: ResolverMap = context.listenerResolvers ?? new Map();
152
+ const skipResolvers: ResolverMap = context.skipListenerResolvers ?? listenerResolvers;
139
153
 
140
154
  const skipLimit = step.skipPolicy?.limit ?? 0;
141
155
  const retryLimit = step.retryPolicy?.limit ?? 0;
@@ -190,6 +204,12 @@ export class ChunkStepExecutor {
190
204
  let openedStreams: ItemStream[] = [];
191
205
  let streamContext: ExecutionContext | null = null;
192
206
 
207
+ await context.listenerInvoker.invokeBefore(
208
+ listenerResolvers,
209
+ 'step',
210
+ this.buildListenerContext(step, context),
211
+ );
212
+
193
213
  try {
194
214
  // Resolve inside the try block so a missing provider-token ref
195
215
  // surfaces as FAILED/{exitMessage: <err>}, matching the tasklet
@@ -233,8 +253,9 @@ export class ChunkStepExecutor {
233
253
  ? Math.max(0, partition.to - partition.from - readCount)
234
254
  : step.chunkSize;
235
255
  if (skipCap === 0) break;
256
+ const itemContext = this.buildItemContext(step, context, chunkIndex);
236
257
  for (let i = 0; i < skipCap; i++) {
237
- const item = await reader.read();
258
+ const item = await reader.read(itemContext);
238
259
  if (item == null) break;
239
260
  drained += 1;
240
261
  }
@@ -255,7 +276,9 @@ export class ChunkStepExecutor {
255
276
  // remaining)` and `0` is a valid "no more items" cap (we
256
277
  // exit the outer loop on the next iteration).
257
278
  const remaining =
258
- partition !== null ? Math.max(0, partition.to - partition.from - readCount) : step.chunkSize;
279
+ partition !== null
280
+ ? Math.max(0, partition.to - partition.from - readCount)
281
+ : step.chunkSize;
259
282
  if (remaining === 0) {
260
283
  // Partition is fully drained. Exit the outer loop so the
261
284
  // chunk executor returns COMPLETED with the partition's
@@ -263,9 +286,14 @@ export class ChunkStepExecutor {
263
286
  break;
264
287
  }
265
288
 
289
+ const itemContext = this.buildItemContext(step, context, chunkIndex);
290
+ const listenerContext = this.buildListenerContext(step, context, { chunkIndex });
291
+
292
+ await context.listenerInvoker.invokeBefore(listenerResolvers, 'chunk', listenerContext);
293
+
266
294
  // ---- READ PHASE: per-item retry+skip ----
267
295
  for (let i = 0; i < remaining && !eof; i++) {
268
- const r = await this.runPhase<unknown>(() => reader.read(), {
296
+ const r = await this.runPhase<unknown>(() => reader.read(itemContext), {
269
297
  phase: 'read',
270
298
  item: null,
271
299
  skipPolicy,
@@ -277,6 +305,25 @@ export class ChunkStepExecutor {
277
305
  skipCount += 1;
278
306
  await context.listenerInvoker.invokeOnSkipRead(skipResolvers, err, null);
279
307
  },
308
+ before: async () => {
309
+ await context.listenerInvoker.invokeBeforeRead(listenerResolvers, listenerContext);
310
+ },
311
+ after: async (item) => {
312
+ if (item !== null && item !== undefined) {
313
+ await context.listenerInvoker.invokeAfterRead(
314
+ listenerResolvers,
315
+ item,
316
+ listenerContext,
317
+ );
318
+ }
319
+ },
320
+ onError: async (err) => {
321
+ await context.listenerInvoker.invokeOnReadError(
322
+ listenerResolvers,
323
+ err,
324
+ listenerContext,
325
+ );
326
+ },
280
327
  });
281
328
  if (r.kind === 'skipped') continue;
282
329
  if (r.value == null) {
@@ -287,7 +334,16 @@ export class ChunkStepExecutor {
287
334
  items.push(r.value);
288
335
  readCount += 1;
289
336
  }
290
- if (items.length === 0) break; // EOF (either before first read or after skips)
337
+ if (items.length === 0) {
338
+ await context.listenerInvoker.invokeAfter(listenerResolvers, 'chunk', listenerContext, {
339
+ status: StepStatus.COMPLETED,
340
+ readCount,
341
+ writeCount,
342
+ skipCount,
343
+ commitCount,
344
+ });
345
+ break; // EOF (either before first read or after skips)
346
+ }
291
347
 
292
348
  // ---- PROCESS PHASE: per-item retry+skip ----
293
349
  const processed: unknown[] = [];
@@ -296,7 +352,7 @@ export class ChunkStepExecutor {
296
352
  processed.push(item);
297
353
  continue;
298
354
  }
299
- const r = await this.runPhase<unknown>(() => processor.process(item), {
355
+ const r = await this.runPhase<unknown>(() => processor.process(item, itemContext), {
300
356
  phase: 'process',
301
357
  item,
302
358
  skipPolicy,
@@ -308,6 +364,29 @@ export class ChunkStepExecutor {
308
364
  skipCount += 1;
309
365
  await context.listenerInvoker.invokeOnSkipProcess(skipResolvers, item, err);
310
366
  },
367
+ before: async () => {
368
+ await context.listenerInvoker.invokeBeforeProcess(
369
+ listenerResolvers,
370
+ item,
371
+ listenerContext,
372
+ );
373
+ },
374
+ after: async (value) => {
375
+ await context.listenerInvoker.invokeAfterProcess(
376
+ listenerResolvers,
377
+ item,
378
+ value,
379
+ listenerContext,
380
+ );
381
+ },
382
+ onError: async (err) => {
383
+ await context.listenerInvoker.invokeOnProcessError(
384
+ listenerResolvers,
385
+ item,
386
+ err,
387
+ listenerContext,
388
+ );
389
+ },
311
390
  });
312
391
  if (r.kind === 'skipped') continue;
313
392
  if (r.value !== null && r.value !== undefined) {
@@ -324,7 +403,7 @@ export class ChunkStepExecutor {
324
403
  const r = await this.runPhase<{ written: number; skipped: number } | void>(
325
404
  () =>
326
405
  context.transactionManager.withTransaction(async () => {
327
- return writer.write(processed);
406
+ return writer.write(processed, itemContext);
328
407
  }),
329
408
  {
330
409
  phase: 'write',
@@ -338,6 +417,29 @@ export class ChunkStepExecutor {
338
417
  skipCount += 1;
339
418
  await context.listenerInvoker.invokeOnSkipWrite(skipResolvers, processed, err);
340
419
  },
420
+ before: async () => {
421
+ await context.listenerInvoker.invokeBeforeWrite(
422
+ listenerResolvers,
423
+ processed,
424
+ listenerContext,
425
+ );
426
+ },
427
+ after: async (value) => {
428
+ await context.listenerInvoker.invokeAfterWrite(
429
+ listenerResolvers,
430
+ processed,
431
+ value,
432
+ listenerContext,
433
+ );
434
+ },
435
+ onError: async (err) => {
436
+ await context.listenerInvoker.invokeOnWriteError(
437
+ listenerResolvers,
438
+ processed,
439
+ err,
440
+ listenerContext,
441
+ );
442
+ },
341
443
  },
342
444
  );
343
445
  if (r.kind === 'ok') {
@@ -370,6 +472,14 @@ export class ChunkStepExecutor {
370
472
  stepExecutionId: context.stepExecutionId,
371
473
  });
372
474
 
475
+ await context.listenerInvoker.invokeAfter(listenerResolvers, 'chunk', listenerContext, {
476
+ status: StepStatus.COMPLETED,
477
+ readCount,
478
+ writeCount,
479
+ skipCount,
480
+ commitCount: commitCount + 1,
481
+ });
482
+
373
483
  commitCount += 1;
374
484
  chunkIndex += 1;
375
485
  }
@@ -378,7 +488,7 @@ export class ChunkStepExecutor {
378
488
  openedStreams = [];
379
489
  await this.closeStreams(streamsToClose);
380
490
 
381
- return {
491
+ const result: ChunkExecutionResult = {
382
492
  status: StepStatus.COMPLETED,
383
493
  exitCode: 'COMPLETED',
384
494
  exitMessage: '',
@@ -387,6 +497,13 @@ export class ChunkStepExecutor {
387
497
  skipCount,
388
498
  commitCount,
389
499
  };
500
+ await context.listenerInvoker.invokeAfter(
501
+ listenerResolvers,
502
+ 'step',
503
+ this.buildListenerContext(step, context),
504
+ result,
505
+ );
506
+ return result;
390
507
  } catch (err) {
391
508
  let finalError = err;
392
509
  const streamsToClose = openedStreams;
@@ -396,7 +513,13 @@ export class ChunkStepExecutor {
396
513
  } catch (closeErr) {
397
514
  finalError = closeErr;
398
515
  }
399
- return {
516
+ await context.listenerInvoker.invokeOnError(
517
+ listenerResolvers,
518
+ 'chunk',
519
+ this.buildListenerContext(step, context, { chunkIndex }),
520
+ finalError,
521
+ );
522
+ const result: ChunkExecutionResult = {
400
523
  status: StepStatus.FAILED,
401
524
  exitCode: 'FAILED',
402
525
  exitMessage: finalError instanceof Error ? finalError.message : String(finalError),
@@ -405,9 +528,64 @@ export class ChunkStepExecutor {
405
528
  skipCount,
406
529
  commitCount,
407
530
  };
531
+ await context.listenerInvoker.invokeOnError(
532
+ listenerResolvers,
533
+ 'step',
534
+ this.buildListenerContext(step, context),
535
+ finalError,
536
+ );
537
+ await context.listenerInvoker.invokeAfter(
538
+ listenerResolvers,
539
+ 'step',
540
+ this.buildListenerContext(step, context),
541
+ result,
542
+ );
543
+ return result;
408
544
  }
409
545
  }
410
546
 
547
+ private buildListenerContext(
548
+ step: ChunkStepDefinition,
549
+ context: ChunkExecutionContext,
550
+ extra: Record<string, unknown> = {},
551
+ ): {
552
+ jobExecutionId: string;
553
+ stepExecutionId: string;
554
+ stepName: string;
555
+ jobParameters: JobParameters;
556
+ [extra: string]: unknown;
557
+ } {
558
+ return {
559
+ jobExecutionId: context.jobExecutionId,
560
+ stepExecutionId: context.stepExecutionId,
561
+ stepName: context.stepName ?? step.id,
562
+ jobParameters: context.jobParameters ?? {},
563
+ ...extra,
564
+ };
565
+ }
566
+
567
+ private buildItemContext(
568
+ step: ChunkStepDefinition,
569
+ context: ChunkExecutionContext,
570
+ chunkIndex: number,
571
+ ): ItemExecutionContext {
572
+ return {
573
+ jobExecutionId: context.jobExecutionId,
574
+ stepExecutionId: context.stepExecutionId,
575
+ stepName: context.stepName ?? step.id,
576
+ jobParameters: context.jobParameters ?? {},
577
+ chunkIndex,
578
+ getExecutionContext: async () =>
579
+ context.jobRepository.getExecutionContext({ stepExecutionId: context.stepExecutionId }),
580
+ saveExecutionContext: async (ctx: ExecutionContext) => {
581
+ await context.jobRepository.saveExecutionContext(
582
+ { stepExecutionId: context.stepExecutionId },
583
+ ctx,
584
+ );
585
+ },
586
+ };
587
+ }
588
+
411
589
  private async updateStreams(
412
590
  streams: readonly ItemStream[],
413
591
  ctx: ExecutionContext,
@@ -443,7 +621,7 @@ export class ChunkStepExecutor {
443
621
  */
444
622
  private async runPhase<T>(
445
623
  op: () => Promise<T>,
446
- options: RunPhaseOptions,
624
+ options: RunPhaseOptions<T>,
447
625
  ): Promise<PhaseResult<T>> {
448
626
  let attempt = 1;
449
627
  // Outer safety cap: when a retry policy exists, allow many iterations;
@@ -452,10 +630,12 @@ export class ChunkStepExecutor {
452
630
  const outerCap = options.retryPolicy ? 999 : 1;
453
631
 
454
632
  while (attempt <= outerCap) {
633
+ if (options.before) await options.before();
634
+ let value: T;
455
635
  try {
456
- const value = await op();
457
- return { kind: 'ok', value };
636
+ value = await op();
458
637
  } catch (err) {
638
+ if (options.onError) await options.onError(err);
459
639
  // 1) Skip consultation: is this error skippable, and is there budget?
460
640
  if (options.skipPolicy) {
461
641
  // Use the policy's `shouldSkip` with `skipCount: 0` to get a pure
@@ -513,6 +693,8 @@ export class ChunkStepExecutor {
513
693
  // 3) Neither skippable nor retryable: re-throw the original error.
514
694
  throw err;
515
695
  }
696
+ if (options.after) await options.after(value);
697
+ return { kind: 'ok', value };
516
698
  }
517
699
 
518
700
  // Defensive: the outer cap should never be reached when a retry policy
@@ -529,7 +711,11 @@ export class ChunkStepExecutor {
529
711
  if (typeof result === 'function') {
530
712
  return { read: result as ItemReader['read'] };
531
713
  }
532
- if (result !== null && typeof result === 'object' && typeof (result as ItemReader).read === 'function') {
714
+ if (
715
+ result !== null &&
716
+ typeof result === 'object' &&
717
+ typeof (result as ItemReader).read === 'function'
718
+ ) {
533
719
  return result as ItemReader;
534
720
  }
535
721
  return { read: ref.fn as ItemReader['read'] };
@@ -552,7 +738,11 @@ export class ChunkStepExecutor {
552
738
  if (typeof result === 'function') {
553
739
  return { process: result as ItemProcessor['process'] };
554
740
  }
555
- if (result !== null && typeof result === 'object' && typeof (result as ItemProcessor).process === 'function') {
741
+ if (
742
+ result !== null &&
743
+ typeof result === 'object' &&
744
+ typeof (result as ItemProcessor).process === 'function'
745
+ ) {
556
746
  return result as ItemProcessor;
557
747
  }
558
748
  return { process: ref.fn as ItemProcessor['process'] };
@@ -575,7 +765,11 @@ export class ChunkStepExecutor {
575
765
  if (typeof result === 'function') {
576
766
  return { write: result as ItemWriter['write'] };
577
767
  }
578
- if (result !== null && typeof result === 'object' && typeof (result as ItemWriter).write === 'function') {
768
+ if (
769
+ result !== null &&
770
+ typeof result === 'object' &&
771
+ typeof (result as ItemWriter).write === 'function'
772
+ ) {
579
773
  return result as ItemWriter;
580
774
  }
581
775
  return { write: ref.fn as ItemWriter['write'] };