fix(api): retry commands that clash with a concurrent write
Services run commands through a CommandRunner that runs one again (up to 5 times, waiting a little longer each time) when its aggregate changed underneath, e.g. a saga writing to the same ticket. Each command loads its aggregate fresh and stores its events in one transaction, so a ConcurrencyError means nothing was stored and running it again is safe. People no longer get a 409 for it, and the e2e suite stops flaking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,8 @@ Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries`
|
||||
- `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`).
|
||||
Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
|
||||
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…).
|
||||
- Los buses CQRS (`CommandBus`, `QueryBus`) solo los usan los `*.service.ts` de `application`; todo lo demás pasa por
|
||||
- Los buses CQRS solo los usan los `*.service.ts` de `application` (los comandos, a través del `CommandRunner`, que
|
||||
los reintenta si chocan; las consultas, con el `QueryBus`); todo lo demás pasa por
|
||||
un servicio: resolvers, controllers, temporizadores (recordatorios, papelera; por eso viven en `presenters`), el
|
||||
handler de eventos de las automatizaciones y el directorio de usuarios del tracker (`IdentityService`). La única
|
||||
excepción es el `EventsBridge`, que ejecuta los comandos que devuelven las sagas.
|
||||
@@ -109,7 +110,10 @@ sequenceDiagram
|
||||
- **Guardar**: el `EventStorePublisher` sustituye al publisher del `EventBus`, así que `commit()` escribe en la tabla
|
||||
`events`. `commitAndWait()` espera a que la escritura termine (el `commit()` de Nest no devuelve la promesa).
|
||||
- **Concurrencia optimista**: `UNIQUE(stream_id, position)`. Si dos peticiones modifican el mismo agregado a la vez, la
|
||||
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`).
|
||||
segunda falla con `ConcurrencyError`. El `CommandRunner` (`shared/application`) la repite sobre el estado nuevo hasta
|
||||
5 veces, con una pequeña espera creciente: cada comando carga su agregado de cero y guarda sus eventos en una sola
|
||||
transacción, así que un choque significa que no se guardó nada. Solo si sigue chocando llega al cliente (código
|
||||
GraphQL `CONFLICT`, HTTP `409`). Las sagas tienen su propio reintento en el `EventsBridge`.
|
||||
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
|
||||
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
|
||||
- **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una
|
||||
|
||||
@@ -665,7 +665,7 @@ tiempo real, las subscriptions de GraphQL.
|
||||
### Errores
|
||||
|
||||
`{ statusCode, code?, message }`: `400` entrada no válida (`message` es una lista), `401` sin token o token rechazado
|
||||
por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (reintentar) y `422` regla de negocio (p. ej. una
|
||||
por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (la API ya lo ha reintentado 5 veces; raro) y `422` regla de negocio (p. ej. una
|
||||
transición que el workflow no permite).
|
||||
|
||||
### Generar un cliente en otra app
|
||||
|
||||
@@ -23,9 +23,6 @@ cuando hay pistas, por dónde empezar.
|
||||
ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir.
|
||||
- **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado.
|
||||
- **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos).
|
||||
- **Escrituras a la vez en un ticket**: si una saga (por ejemplo, apuntar como seguidor a quien se asigna) escribe en
|
||||
el mismo ticket justo cuando alguien lo cambia, la petición de esa persona recibe un 409 y hay que repetirla. Con la
|
||||
máquina muy cargada salta en las pruebas e2e de vez en cuando. Reintentar sola la orden ante un `ConcurrencyError`.
|
||||
- **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y
|
||||
renovarlo a mano cuando caduca.
|
||||
|
||||
@@ -120,3 +117,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
||||
ritmo real (Monte Carlo), carga del equipo en puntos y salud del backlog con enlaces a la lista filtrada.
|
||||
- Panel del proyecto, tercera parte: calidad (bugs abiertos por prioridad, nuevos y cerrados por semana, QA por área,
|
||||
bugs tras cada release) y abiertos por opción de un campo de lista.
|
||||
- Escrituras a la vez en un ticket: la API repite sola la orden que choca con otra (por ejemplo, con una saga), en
|
||||
lugar de devolver un 409.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
AuthorizationConfig,
|
||||
TokenGrant,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
@Injectable()
|
||||
export class IdentityService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -44,14 +45,12 @@ export class IdentityService {
|
||||
}
|
||||
|
||||
handleWebhook(event: string, data: Record<string, unknown>): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new HandleIdentityWebhookCommand(event, data),
|
||||
);
|
||||
return this.commands.execute(new HandleIdentityWebhookCommand(event, data));
|
||||
}
|
||||
|
||||
/** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */
|
||||
issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||
return this.commandBus.execute(new IssueTokenCommand(grant));
|
||||
return this.commands.execute(new IssueTokenCommand(grant));
|
||||
}
|
||||
|
||||
authorizationConfig(): Promise<AuthorizationConfig | null> {
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './lib/ports/event-store';
|
||||
export * from './lib/aggregate-rehydrator';
|
||||
export * from './lib/shared.module';
|
||||
export * from './lib/event-recorder';
|
||||
export * from './lib/command-runner';
|
||||
|
||||
48
libs/api/shared/application/src/lib/command-runner.spec.ts
Normal file
48
libs/api/shared/application/src/lib/command-runner.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Command, CommandBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ConcurrencyError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { COMMAND_ATTEMPTS, CommandRunner } from './command-runner';
|
||||
|
||||
class Rename extends Command<string> {}
|
||||
|
||||
const busFailing = (errors: Error[]) => {
|
||||
const execute = jest.fn(async () => {
|
||||
const error = errors.shift();
|
||||
if (error) throw error;
|
||||
return 'done';
|
||||
});
|
||||
return { bus: { execute } as unknown as CommandBus, execute };
|
||||
};
|
||||
|
||||
describe('CommandRunner', () => {
|
||||
it('runs a command again while its aggregate changed underneath', async () => {
|
||||
const { bus, execute } = busFailing([
|
||||
new ConcurrencyError('Aggregate', 'a'),
|
||||
new ConcurrencyError('Aggregate', 'a'),
|
||||
]);
|
||||
await expect(new CommandRunner(bus).execute(new Rename())).resolves.toBe(
|
||||
'done',
|
||||
);
|
||||
expect(execute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('gives up after a few conflicts, and never retries other errors', async () => {
|
||||
const conflicts = Array.from(
|
||||
{ length: COMMAND_ATTEMPTS },
|
||||
() => new ConcurrencyError('Aggregate', 'a'),
|
||||
);
|
||||
const stubborn = busFailing(conflicts);
|
||||
await expect(
|
||||
new CommandRunner(stubborn.bus).execute(new Rename()),
|
||||
).rejects.toBeInstanceOf(ConcurrencyError);
|
||||
expect(stubborn.execute).toHaveBeenCalledTimes(COMMAND_ATTEMPTS);
|
||||
|
||||
const refused = busFailing([new BusinessRuleError('No')]);
|
||||
await expect(
|
||||
new CommandRunner(refused.bus).execute(new Rename()),
|
||||
).rejects.toBeInstanceOf(BusinessRuleError);
|
||||
expect(refused.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
31
libs/api/shared/application/src/lib/command-runner.ts
Normal file
31
libs/api/shared/application/src/lib/command-runner.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Command, CommandBus } from '@nestjs/cqrs';
|
||||
import { ConcurrencyError } from '@task-stream/api/shared/domain';
|
||||
|
||||
/** Attempts before a conflict reaches the caller (as a 409). */
|
||||
export const COMMAND_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Runs commands for the application services, and runs one again when its
|
||||
* aggregate changed underneath (someone, or a saga, wrote to it at the same
|
||||
* moment). Every command loads its aggregate fresh and stores its events in
|
||||
* one transaction, so a ConcurrencyError means nothing was stored: running
|
||||
* it again on the new state is safe.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CommandRunner {
|
||||
constructor(private readonly commandBus: CommandBus) {}
|
||||
|
||||
async execute<R>(command: Command<R>): Promise<R> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await this.commandBus.execute(command);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ConcurrencyError) || attempt >= COMMAND_ATTEMPTS)
|
||||
throw error;
|
||||
// A little later each time, so the other writer can finish.
|
||||
await new Promise((resolve) => setTimeout(resolve, 25 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { DynamicModule, Global, Module, Type } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AggregateRehydrator } from './aggregate-rehydrator';
|
||||
import { CommandRunner } from './command-runner';
|
||||
import { EventRecorder } from './event-recorder';
|
||||
|
||||
/**
|
||||
* Shared building blocks for every bounded context: the event store (from
|
||||
* the infrastructure module plugged in by the composition root), the
|
||||
* AggregateRehydrator and the EventRecorder.
|
||||
* AggregateRehydrator, the EventRecorder and the CommandRunner.
|
||||
*/
|
||||
@Global()
|
||||
@Module({})
|
||||
@@ -17,8 +18,13 @@ export class SharedModule {
|
||||
return {
|
||||
module: SharedModule,
|
||||
imports: [CqrsModule, infrastructureModule],
|
||||
providers: [AggregateRehydrator, EventRecorder],
|
||||
exports: [infrastructureModule, AggregateRehydrator, EventRecorder],
|
||||
providers: [AggregateRehydrator, EventRecorder, CommandRunner],
|
||||
exports: [
|
||||
infrastructureModule,
|
||||
AggregateRehydrator,
|
||||
EventRecorder,
|
||||
CommandRunner,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
AutomationInput,
|
||||
CreateAutomationCommand,
|
||||
@@ -14,7 +15,7 @@ import { Automation } from './ports/automations.repository';
|
||||
@Injectable()
|
||||
export class AutomationsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -27,7 +28,7 @@ export class AutomationsService {
|
||||
projectId: string,
|
||||
rule: AutomationInput,
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateAutomationCommand(actorId, projectId, rule),
|
||||
);
|
||||
}
|
||||
@@ -37,20 +38,18 @@ export class AutomationsService {
|
||||
automationId: string,
|
||||
changes: { name?: string; active?: boolean },
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateAutomationCommand(actorId, automationId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs a rule on a ticket; the error, if any, as text. */
|
||||
run(automation: Automation, issueId: string): Promise<string | null> {
|
||||
return this.commandBus.execute(
|
||||
new RunAutomationCommand(automation, issueId),
|
||||
);
|
||||
return this.commands.execute(new RunAutomationCommand(automation, issueId));
|
||||
}
|
||||
|
||||
delete(actorId: string, automationId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new DeleteAutomationCommand(actorId, automationId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
CreateCalendarFeedCommand,
|
||||
GetCalendarIcsQuery,
|
||||
@@ -12,7 +13,7 @@ import { CalendarFeed } from './ports/calendar-feeds.repository';
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -22,11 +23,11 @@ export class CalendarService {
|
||||
|
||||
/** A new secret URL; the previous one, if any, stops working. */
|
||||
create(userId: string): Promise<CalendarFeed> {
|
||||
return this.commandBus.execute(new CreateCalendarFeedCommand(userId));
|
||||
return this.commands.execute(new CreateCalendarFeedCommand(userId));
|
||||
}
|
||||
|
||||
revoke(userId: string): Promise<void> {
|
||||
return this.commandBus.execute(new RevokeCalendarFeedCommand(userId));
|
||||
return this.commands.execute(new RevokeCalendarFeedCommand(userId));
|
||||
}
|
||||
|
||||
/** The calendar behind `token`, or null if no feed has it. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommandBus, CqrsModule, EventBus } from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
AggregateRehydrator,
|
||||
CommandRunner,
|
||||
EventStore,
|
||||
} from '@task-stream/api/shared/application';
|
||||
import {
|
||||
@@ -151,6 +152,7 @@ async function setup() {
|
||||
},
|
||||
{ provide: GitIntegrationsRepository, useValue: git },
|
||||
AggregateRehydrator,
|
||||
CommandRunner,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: AttachmentStorage, useValue: files },
|
||||
{ provide: UserDirectory, useValue: users },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { CommentReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from './commands/add-comment.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
@@ -12,7 +13,7 @@ import { GetCommentsQuery } from './queries/get-comments.query';
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -22,7 +23,7 @@ export class CommentsService {
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AddCommentCommand(actorId, issueId, body, replyTo),
|
||||
);
|
||||
}
|
||||
@@ -32,7 +33,7 @@ export class CommentsService {
|
||||
commentId: string,
|
||||
body: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new EditCommentCommand(actorId, commentId, body),
|
||||
);
|
||||
}
|
||||
@@ -44,15 +45,13 @@ export class CommentsService {
|
||||
emoji: string,
|
||||
on: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new ReactToCommentCommand(actorId, commentId, emoji, on),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, commentId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteCommentCommand(actorId, commentId),
|
||||
);
|
||||
return this.commands.execute(new DeleteCommentCommand(actorId, commentId));
|
||||
}
|
||||
|
||||
list(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { RecordGitMentionsCommand } from './git/git';
|
||||
@@ -28,7 +29,7 @@ describe('GitService', () => {
|
||||
executed.push(command);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as CommandBus,
|
||||
} as unknown as CommandRunner,
|
||||
{} as QueryBus,
|
||||
{
|
||||
findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
AddGitRepositoryCommand,
|
||||
@@ -30,7 +31,7 @@ export interface GitDelivery {
|
||||
@Injectable()
|
||||
export class GitService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
@@ -47,7 +48,7 @@ export class GitService {
|
||||
projectId: string,
|
||||
name: string,
|
||||
): Promise<GitRepository> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AddGitRepositoryCommand(actorId, projectId, name),
|
||||
);
|
||||
}
|
||||
@@ -57,7 +58,7 @@ export class GitService {
|
||||
projectId: string,
|
||||
repositoryId: string,
|
||||
): Promise<GitRepository> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId),
|
||||
);
|
||||
}
|
||||
@@ -67,7 +68,7 @@ export class GitService {
|
||||
projectId: string,
|
||||
repositoryId: string,
|
||||
): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RemoveGitRepositoryCommand(actorId, projectId, repositoryId),
|
||||
);
|
||||
}
|
||||
@@ -104,7 +105,7 @@ export class GitService {
|
||||
delivery.body,
|
||||
);
|
||||
if (!mentions.length) return { recorded: 0 };
|
||||
const recorded: number = await this.commandBus.execute(
|
||||
const recorded: number = await this.commands.execute(
|
||||
new RecordGitMentionsCommand(project.id, repository.name, mentions),
|
||||
);
|
||||
return { recorded };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
@@ -79,7 +80,7 @@ export interface IssueLinkInput {
|
||||
@Injectable()
|
||||
export class IssuesService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
@@ -97,7 +98,7 @@ export class IssuesService {
|
||||
useTemplate,
|
||||
...details
|
||||
} = input;
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateIssueCommand(
|
||||
actorId,
|
||||
projectId,
|
||||
@@ -118,7 +119,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
changes: IssueDetails,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateIssueCommand(actorId, issueId, changes),
|
||||
);
|
||||
}
|
||||
@@ -129,7 +130,7 @@ export class IssuesService {
|
||||
status: string,
|
||||
reason?: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new ChangeIssueStatusCommand(actorId, issueId, status, reason),
|
||||
);
|
||||
}
|
||||
@@ -140,13 +141,13 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
assigneeId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AssignIssueCommand(actorId, issueId, assigneeId),
|
||||
);
|
||||
}
|
||||
|
||||
link(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new LinkIssuesCommand(
|
||||
actorId,
|
||||
input.issueId,
|
||||
@@ -157,7 +158,7 @@ export class IssuesService {
|
||||
}
|
||||
|
||||
unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UnlinkIssuesCommand(
|
||||
actorId,
|
||||
input.issueId,
|
||||
@@ -173,7 +174,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
releaseId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new SetIssueReleaseCommand(actorId, issueId, releaseId),
|
||||
);
|
||||
}
|
||||
@@ -185,7 +186,7 @@ export class IssuesService {
|
||||
file: IncomingFile,
|
||||
commentId: string | null = null,
|
||||
): Promise<AttachmentResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AttachFileCommand(actorId, issueId, file, commentId),
|
||||
);
|
||||
}
|
||||
@@ -195,7 +196,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new DetachFileCommand(actorId, issueId, attachmentId),
|
||||
);
|
||||
}
|
||||
@@ -206,7 +207,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
watching: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new WatchIssueCommand(actorId, issueId, actorId, watching),
|
||||
);
|
||||
}
|
||||
@@ -236,7 +237,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new SetIssueParentCommand(actorId, issueId, parentId),
|
||||
);
|
||||
}
|
||||
@@ -248,7 +249,7 @@ export class IssuesService {
|
||||
above: string | null,
|
||||
below: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RankIssueCommand(actorId, issueId, above, below),
|
||||
);
|
||||
}
|
||||
@@ -278,7 +279,7 @@ export class IssuesService {
|
||||
if (change.assigneeId !== undefined)
|
||||
await this.assign(actorId, issueId, change.assigneeId);
|
||||
if (change.sprintId !== undefined)
|
||||
await this.commandBus.execute(
|
||||
await this.commands.execute(
|
||||
new SetIssueSprintCommand(actorId, issueId, change.sprintId),
|
||||
);
|
||||
if (change.releaseId !== undefined)
|
||||
@@ -306,12 +307,12 @@ export class IssuesService {
|
||||
|
||||
/** To the trash (whoever opened it, or a manager); back with `restore`. */
|
||||
trash(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
|
||||
return this.commands.execute(new TrashIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
/** Deletes it for good (the trash is emptied this way). */
|
||||
delete(actorId: string, issueId: string): Promise<void> {
|
||||
return this.commandBus.execute(new DeleteIssueCommand(actorId, issueId));
|
||||
return this.commands.execute(new DeleteIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,12 +327,12 @@ export class IssuesService {
|
||||
}
|
||||
|
||||
restore(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
|
||||
return this.commands.execute(new RestoreIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
/** Votes for the ticket (`on`) or takes the vote back. */
|
||||
vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on));
|
||||
return this.commands.execute(new VoteIssueCommand(actorId, issueId, on));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,7 +379,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
text: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AddChecklistItemCommand(actorId, issueId, text),
|
||||
);
|
||||
}
|
||||
@@ -389,7 +390,7 @@ export class IssuesService {
|
||||
itemId: string,
|
||||
changes: { text?: string; done?: boolean },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
|
||||
);
|
||||
}
|
||||
@@ -399,7 +400,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RemoveChecklistItemCommand(actorId, issueId, itemId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { SendDueRemindersCommand } from './notifications/reminders';
|
||||
import {
|
||||
@@ -20,7 +21,7 @@ import { ChangeFeed } from './ports/change-feed';
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
) {}
|
||||
@@ -45,25 +46,23 @@ export class NotificationsService {
|
||||
endpoint: string,
|
||||
keys: { p256dh: string; auth: string },
|
||||
): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new SubscribePushCommand(userId, endpoint, keys),
|
||||
);
|
||||
}
|
||||
|
||||
/** Push off on this device. */
|
||||
unsubscribePush(userId: string, endpoint: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new UnsubscribePushCommand(userId, endpoint),
|
||||
);
|
||||
return this.commands.execute(new UnsubscribePushCommand(userId, endpoint));
|
||||
}
|
||||
|
||||
/** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */
|
||||
sendDueReminders(day: string): Promise<number> {
|
||||
return this.commandBus.execute(new SendDueRemindersCommand(day));
|
||||
return this.commands.execute(new SendDueRemindersCommand(day));
|
||||
}
|
||||
|
||||
markRead(userId: string, ids?: string[] | null): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),
|
||||
);
|
||||
}
|
||||
@@ -76,7 +75,7 @@ export class NotificationsService {
|
||||
userId: string,
|
||||
changes: { email: boolean },
|
||||
): Promise<MyNotificationSettings> {
|
||||
await this.commandBus.execute(
|
||||
await this.commands.execute(
|
||||
new UpdateNotificationSettingsCommand(userId, changes.email),
|
||||
);
|
||||
return this.settings(userId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
@@ -50,7 +51,7 @@ import { Automation } from './ports/automations.repository';
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
@@ -92,7 +93,7 @@ export class ProjectsService {
|
||||
const signer = author?.trim().slice(0, 80);
|
||||
const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`;
|
||||
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateIssueCommand(
|
||||
SYSTEM_ACTOR_ID,
|
||||
project.id,
|
||||
@@ -139,7 +140,7 @@ export class ProjectsService {
|
||||
const template = input.templateId
|
||||
? await this.template(actorId, input.templateId)
|
||||
: null;
|
||||
const created: CommandResult = await this.commandBus.execute(
|
||||
const created: CommandResult = await this.commands.execute(
|
||||
new CreateProjectCommand(
|
||||
actorId,
|
||||
input.key,
|
||||
@@ -149,7 +150,7 @@ export class ProjectsService {
|
||||
),
|
||||
);
|
||||
if (!template) return created;
|
||||
const configured: CommandResult = await this.commandBus.execute(
|
||||
const configured: CommandResult = await this.commands.execute(
|
||||
new UpdateProjectSettingsCommand(
|
||||
actorId,
|
||||
created.id,
|
||||
@@ -182,7 +183,7 @@ export class ProjectsService {
|
||||
rule: Automation,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const copy: Automation = await this.commandBus.execute(
|
||||
const copy: Automation = await this.commands.execute(
|
||||
new CreateAutomationCommand(actorId, projectId, {
|
||||
name: rule.name,
|
||||
trigger: rule.trigger,
|
||||
@@ -192,7 +193,7 @@ export class ProjectsService {
|
||||
}),
|
||||
);
|
||||
if (!rule.active)
|
||||
await this.commandBus.execute(
|
||||
await this.commands.execute(
|
||||
new UpdateAutomationCommand(actorId, copy.id, { active: false }),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -215,7 +216,7 @@ export class ProjectsService {
|
||||
acceptsProposals?: boolean;
|
||||
},
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateProjectCommand(actorId, projectId, changes),
|
||||
);
|
||||
}
|
||||
@@ -229,7 +230,7 @@ export class ProjectsService {
|
||||
projectId: string,
|
||||
confirmKey: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new DeleteProjectCommand(actorId, projectId, confirmKey),
|
||||
);
|
||||
}
|
||||
@@ -245,7 +246,7 @@ export class ProjectsService {
|
||||
projectId: string,
|
||||
settings: ProjectSettings,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateProjectSettingsCommand(actorId, projectId, settings),
|
||||
);
|
||||
}
|
||||
@@ -255,7 +256,7 @@ export class ProjectsService {
|
||||
input: { projectId: string; userId: string; role: ProjectRole },
|
||||
accessToken?: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AddProjectMemberCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
@@ -270,7 +271,7 @@ export class ProjectsService {
|
||||
actorId: string,
|
||||
input: { projectId: string; userId: string; role: ProjectRole },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new ChangeProjectMemberRoleCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
@@ -285,7 +286,7 @@ export class ProjectsService {
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RemoveProjectMemberCommand(actorId, projectId, userId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ReleaseBurnupReadModel,
|
||||
ReleaseProgressReadModel,
|
||||
@@ -26,7 +27,7 @@ interface ReleaseDetails {
|
||||
@Injectable()
|
||||
export class ReleasesService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -34,7 +35,7 @@ export class ReleasesService {
|
||||
actorId: string,
|
||||
input: ReleaseDetails & { projectId: string; name: string },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateReleaseCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
@@ -50,7 +51,7 @@ export class ReleasesService {
|
||||
releaseId: string,
|
||||
changes: ReleaseDetails,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateReleaseCommand(actorId, releaseId, changes),
|
||||
);
|
||||
}
|
||||
@@ -61,7 +62,7 @@ export class ReleasesService {
|
||||
releaseId: string,
|
||||
force = false,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new PublishReleaseCommand(actorId, releaseId, force),
|
||||
);
|
||||
}
|
||||
@@ -72,7 +73,7 @@ export class ReleasesService {
|
||||
releaseId: string,
|
||||
moveTo: string | null = null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new ArchiveReleaseCommand(actorId, releaseId, moveTo),
|
||||
);
|
||||
}
|
||||
@@ -95,7 +96,7 @@ export class ReleasesService {
|
||||
releaseId: string,
|
||||
notes: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new WriteReleaseNotesCommand(actorId, releaseId, notes),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
SavedFilterCriteriaReadModel,
|
||||
SavedFilterReadModel,
|
||||
@@ -21,7 +22,7 @@ export interface SaveFilterInput {
|
||||
@Injectable()
|
||||
export class SavedFiltersService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -30,7 +31,7 @@ export class SavedFiltersService {
|
||||
}
|
||||
|
||||
save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new SaveFilterCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
@@ -42,7 +43,7 @@ export class SavedFiltersService {
|
||||
}
|
||||
|
||||
delete(actorId: string, filterId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new DeleteSavedFilterCommand(actorId, filterId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintBurndownReadModel,
|
||||
SprintDetails,
|
||||
@@ -34,7 +35,7 @@ export interface CreateSprintInput {
|
||||
@Injectable()
|
||||
export class SprintsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -65,7 +66,7 @@ export class SprintsService {
|
||||
}
|
||||
|
||||
create(actorId: string, input: CreateSprintInput): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateSprintCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
@@ -82,13 +83,13 @@ export class SprintsService {
|
||||
sprintId: string,
|
||||
changes: SprintDetails,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateSprintCommand(actorId, sprintId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
start(actorId: string, sprintId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new StartSprintCommand(actorId, sprintId));
|
||||
return this.commands.execute(new StartSprintCommand(actorId, sprintId));
|
||||
}
|
||||
|
||||
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */
|
||||
@@ -97,14 +98,14 @@ export class SprintsService {
|
||||
sprintId: string,
|
||||
carryOverTo: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CloseSprintCommand(actorId, sprintId, carryOverTo),
|
||||
);
|
||||
}
|
||||
|
||||
/** Planned sprints only; its tickets go back to the backlog. */
|
||||
delete(actorId: string, sprintId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new DeleteSprintCommand(actorId, sprintId),
|
||||
) as Promise<CommandResult>;
|
||||
}
|
||||
@@ -115,7 +116,7 @@ export class SprintsService {
|
||||
issueId: string,
|
||||
sprintId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new SetIssueSprintCommand(actorId, issueId, sprintId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { WebhookDelivery } from './ports/outgoing-webhooks.repository';
|
||||
import { WEBHOOK_EVENT_NAMES } from './webhooks/webhook-events';
|
||||
import {
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
@Injectable()
|
||||
export class WebhooksService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -35,7 +36,7 @@ export class WebhooksService {
|
||||
url: string,
|
||||
events: string[],
|
||||
): Promise<{ id: string; secret: string }> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateWebhookCommand(actorId, projectId, url, events),
|
||||
);
|
||||
}
|
||||
@@ -45,18 +46,16 @@ export class WebhooksService {
|
||||
webhookId: string,
|
||||
changes: { url?: string; events?: string[]; active?: boolean },
|
||||
): Promise<WebhookView> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateWebhookCommand(actorId, webhookId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, webhookId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteWebhookCommand(actorId, webhookId),
|
||||
);
|
||||
return this.commands.execute(new DeleteWebhookCommand(actorId, webhookId));
|
||||
}
|
||||
|
||||
test(actorId: string, webhookId: string): Promise<WebhookDelivery> {
|
||||
return this.commandBus.execute(new TestWebhookCommand(actorId, webhookId));
|
||||
return this.commands.execute(new TestWebhookCommand(actorId, webhookId));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user