related to https://github.com/twentyhq/core-team-issues/issues/601

## Done
- add a `onDbEvent` `Subscription` graphql endpoint to listen to
database_event using what we have done with webhooks:
- you can subscribe to any `action` (created, updated, ...) for any
`objectNameSingular` or a specific `recordId`. Parameters are nullable
and treated as wildcards when null.
  - returns events with following shape
```typescript
  @Field(() => String)
  eventId: string;

  @Field()
  emittedAt: string;

  @Field(() => DatabaseEventAction)
  action: DatabaseEventAction;

  @Field(() => String)
  objectNameSingular: string;

  @Field(() => GraphQLJSON)
  record: ObjectRecord;

  @Field(() => [String], { nullable: true })
  updatedFields?: string[];
```
- front provide a componentEffect `<ListenRecordUpdatesEffect />` that
listen for an `objectNameSingular`, a `recordId` and a list of
`listenedFields`. It subscribes to record updates and updates its apollo
cached value for specified `listenedFields`
- subscription is protected with credentials

## Result

Here is an application with `workflowRun`


https://github.com/user-attachments/assets/c964d857-3b54-495f-bf14-587ba26c5a8c

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
martmull
2025-04-17 16:03:51 +02:00
committed by GitHub
parent b112d06f66
commit 42e060ac74
25 changed files with 552 additions and 27 deletions

View File

@ -12,4 +12,5 @@ export enum WorkflowRunExceptionCode {
INVALID_INPUT = 'INVALID_INPUT',
WORKFLOW_RUN_LIMIT_REACHED = 'WORKFLOW_RUN_LIMIT_REACHED',
WORKFLOW_RUN_INVALID = 'WORKFLOW_RUN_INVALID',
FAILURE = 'FAILURE',
}

View File

@ -1,12 +1,19 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Module({
imports: [WorkflowCommonModule, RecordPositionModule],
imports: [
WorkflowCommonModule,
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
RecordPositionModule,
],
providers: [WorkflowRunWorkspaceService, ScopedWorkspaceContextFactory],
exports: [WorkflowRunWorkspaceService],
})

View File

@ -1,4 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
@ -17,14 +20,20 @@ import {
WorkflowRunException,
WorkflowRunExceptionCode,
} from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@Injectable()
export class WorkflowRunWorkspaceService {
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
private readonly recordPositionService: RecordPositionService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly recordPositionService: RecordPositionService,
) {}
async createWorkflowRun({
@ -131,11 +140,19 @@ export class WorkflowRunWorkspaceService {
);
}
return workflowRunRepository.update(workflowRunToUpdate.id, {
const partialUpdate = {
status: WorkflowRunStatus.RUNNING,
startedAt: new Date().toISOString(),
context,
output,
};
await workflowRunRepository.update(workflowRunToUpdate.id, partialUpdate);
await this.emitWorkflowRunUpdatedEvent({
workflowRunBefore: workflowRunToUpdate,
diff: partialUpdate,
updatedFields: ['status', 'startedAt', 'context', 'output'],
});
}
@ -164,13 +181,21 @@ export class WorkflowRunWorkspaceService {
);
}
return workflowRunRepository.update(workflowRunToUpdate.id, {
const partialUpdate = {
status,
endedAt: new Date().toISOString(),
output: {
...(workflowRunToUpdate.output ?? {}),
error,
},
};
await workflowRunRepository.update(workflowRunToUpdate.id, partialUpdate);
await this.emitWorkflowRunUpdatedEvent({
workflowRunBefore: workflowRunToUpdate,
diff: partialUpdate,
updatedFields: ['status', 'endedAt', 'output'],
});
}
@ -199,7 +224,7 @@ export class WorkflowRunWorkspaceService {
);
}
return workflowRunRepository.update(workflowRunId, {
const partialUpdate = {
output: {
flow: workflowRunToUpdate.output?.flow ?? {
trigger: undefined,
@ -211,6 +236,14 @@ export class WorkflowRunWorkspaceService {
},
},
context,
};
await workflowRunRepository.update(workflowRunId, partialUpdate);
await this.emitWorkflowRunUpdatedEvent({
workflowRunBefore: workflowRunToUpdate,
diff: partialUpdate,
updatedFields: ['context', 'output'],
});
}
@ -251,7 +284,7 @@ export class WorkflowRunWorkspaceService {
(existingStep) => (step.id === existingStep.id ? step : existingStep),
);
return workflowRunRepository.update(workflowRunToUpdate.id, {
const partialUpdate = {
output: {
...(workflowRunToUpdate.output ?? {}),
flow: {
@ -259,6 +292,14 @@ export class WorkflowRunWorkspaceService {
steps: updatedSteps,
},
},
};
await workflowRunRepository.update(workflowRunToUpdate.id, partialUpdate);
await this.emitWorkflowRunUpdatedEvent({
workflowRunBefore: workflowRunToUpdate,
diff: partialUpdate,
updatedFields: ['output'],
});
}
@ -283,4 +324,68 @@ export class WorkflowRunWorkspaceService {
return workflowRun;
}
private async emitWorkflowRunUpdatedEvent({
workflowRunBefore,
updatedFields,
diff,
}: {
workflowRunBefore: WorkflowRunWorkspaceEntity;
updatedFields: string[];
diff: object;
}) {
const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId;
if (!workspaceId) {
return;
}
const objectMetadata = await this.objectMetadataRepository.findOne({
where: {
nameSingular: 'workflowRun',
workspaceId,
},
});
if (!objectMetadata) {
throw new WorkflowRunException(
'Object metadata not found',
WorkflowRunExceptionCode.FAILURE,
);
}
const workflowRunRepository =
await this.twentyORMManager.getRepository<WorkflowRunWorkspaceEntity>(
'workflowRun',
);
const workflowRunAfter = await workflowRunRepository.findOneBy({
id: workflowRunBefore.id,
});
if (!workflowRunAfter) {
throw new WorkflowRunException(
'WorkflowRun not found',
WorkflowRunExceptionCode.FAILURE,
);
}
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: 'workflowRun',
action: DatabaseEventAction.UPDATED,
events: [
{
recordId: workflowRunBefore.id,
objectMetadata,
properties: {
after: workflowRunAfter,
before: workflowRunBefore,
updatedFields,
diff,
},
},
],
workspaceId,
});
}
}