Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s
356 lines
12 KiB
Markdown
356 lines
12 KiB
Markdown
---
|
|
name: syncable-entity-runner-and-actions
|
|
description: Contributing to the Twenty codebase itself (twentyhq/twenty server internals), not for building apps on top of Twenty. Implement action handlers for executing workspace migrations in Twenty. Use when creating database operations for syncable entities, implementing universal-to-flat entity transpilation, or handling create/update/delete actions in the runner layer.
|
|
---
|
|
|
|
# Syncable Entity: Runner & Actions (Step 4/6)
|
|
|
|
**Purpose**: Execute migration actions against the database with proper transpilation from universal to flat entities.
|
|
|
|
**When to use**: After completing Steps 1-3 (Types, Cache, Builder). Required before integration.
|
|
|
|
---
|
|
|
|
## Quick Start
|
|
|
|
This step creates:
|
|
1. Create action handler
|
|
2. Update action handler
|
|
3. Delete action handler
|
|
4. Universal-to-flat conversion utilities
|
|
|
|
**Key pattern**: Each handler has two phases:
|
|
1. **Transpilation**: Universal action → Flat action
|
|
2. **Execution**: Flat action → Database operation
|
|
|
|
---
|
|
|
|
## Step 1: Create Action Handler
|
|
|
|
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
|
|
import { WorkspaceCreateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-create-action-handler.service';
|
|
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
|
|
import {
|
|
type UniversalCreateMyEntityAction,
|
|
type FlatCreateMyEntityAction,
|
|
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
|
|
|
|
@Injectable()
|
|
export class CreateMyEntityActionHandlerService extends WorkspaceCreateActionHandlerService<
|
|
'myEntity',
|
|
UniversalCreateMyEntityAction,
|
|
FlatCreateMyEntityAction
|
|
> {
|
|
constructor(
|
|
@InjectRepository(MyEntityEntity, 'metadata')
|
|
private readonly myEntityRepository: Repository<MyEntityEntity>,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
// Phase 1: Transpile universal action to flat action
|
|
protected transpileUniversalActionToFlatAction(
|
|
universalAction: UniversalCreateMyEntityAction,
|
|
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
|
): FlatCreateMyEntityAction {
|
|
return {
|
|
type: 'create',
|
|
metadataName: 'myEntity',
|
|
flatEntity: fromUniversalFlatMyEntityToFlatMyEntity(
|
|
universalAction.universalFlatEntity,
|
|
flatEntityMaps,
|
|
),
|
|
};
|
|
}
|
|
|
|
// Phase 2: Execute flat action against database
|
|
protected async executeForMetadata(
|
|
flatActions: FlatCreateMyEntityAction[],
|
|
): Promise<void> {
|
|
const flatEntities = flatActions.map((action) => action.flatEntity);
|
|
|
|
await this.insertFlatEntitiesInRepository({
|
|
repository: this.myEntityRepository,
|
|
flatEntities,
|
|
});
|
|
}
|
|
|
|
protected async executeForWorkspaceSchema(): Promise<void> {
|
|
// No workspace schema changes needed for metadata-only entity
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key helper methods**:
|
|
- `transpileUniversalActionToFlatAction`: Converts universal → flat
|
|
- `insertFlatEntitiesInRepository`: Base class helper for inserts
|
|
- `executeForMetadata`: Metadata database operations
|
|
- `executeForWorkspaceSchema`: Workspace schema changes (if needed)
|
|
|
|
---
|
|
|
|
## Step 2: Update Action Handler
|
|
|
|
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
|
|
import { WorkspaceUpdateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-update-action-handler.service';
|
|
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
|
|
import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util';
|
|
|
|
@Injectable()
|
|
export class UpdateMyEntityActionHandlerService extends WorkspaceUpdateActionHandlerService<
|
|
'myEntity',
|
|
UniversalUpdateMyEntityAction,
|
|
FlatUpdateMyEntityAction
|
|
> {
|
|
constructor(
|
|
@InjectRepository(MyEntityEntity, 'metadata')
|
|
private readonly myEntityRepository: Repository<MyEntityEntity>,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
protected transpileUniversalActionToFlatAction(
|
|
universalAction: UniversalUpdateMyEntityAction,
|
|
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
|
): FlatUpdateMyEntityAction {
|
|
const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity(
|
|
universalAction.universalFlatEntity,
|
|
flatEntityMaps,
|
|
);
|
|
|
|
// Resolve universal foreign keys in updates to regular IDs
|
|
const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({
|
|
metadataName: 'myEntity',
|
|
universalUpdates: universalAction.universalUpdates,
|
|
flatEntityMaps,
|
|
});
|
|
|
|
return {
|
|
type: 'update',
|
|
metadataName: 'myEntity',
|
|
flatEntity,
|
|
updates: flatUpdates,
|
|
};
|
|
}
|
|
|
|
protected async executeForMetadata(
|
|
flatActions: FlatUpdateMyEntityAction[],
|
|
): Promise<void> {
|
|
for (const action of flatActions) {
|
|
await this.myEntityRepository.update(
|
|
{ id: action.flatEntity.id },
|
|
action.updates,
|
|
);
|
|
}
|
|
}
|
|
|
|
protected async executeForWorkspaceSchema(): Promise<void> {
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Update-specific helper**:
|
|
- `resolveUniversalUpdateRelationIdentifiersToIds`: Maps universal identifiers back to regular IDs in the updates object
|
|
|
|
---
|
|
|
|
## Step 3: Delete Action Handler
|
|
|
|
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
|
|
import { WorkspaceDeleteActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-delete-action-handler.service';
|
|
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
|
|
|
|
@Injectable()
|
|
export class DeleteMyEntityActionHandlerService extends WorkspaceDeleteActionHandlerService<
|
|
'myEntity',
|
|
UniversalDeleteMyEntityAction,
|
|
FlatDeleteMyEntityAction
|
|
> {
|
|
constructor(
|
|
@InjectRepository(MyEntityEntity, 'metadata')
|
|
private readonly myEntityRepository: Repository<MyEntityEntity>,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
protected transpileUniversalActionToFlatAction(
|
|
universalAction: UniversalDeleteMyEntityAction,
|
|
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
|
): FlatDeleteMyEntityAction {
|
|
// Use base class helper for delete transpilation
|
|
return this.transpileUniversalDeleteActionToFlatDeleteAction({
|
|
universalAction,
|
|
flatEntityMaps,
|
|
fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity,
|
|
});
|
|
}
|
|
|
|
protected async executeForMetadata(
|
|
flatActions: FlatDeleteMyEntityAction[],
|
|
): Promise<void> {
|
|
const ids = flatActions.map((action) => action.flatEntity.id);
|
|
|
|
await this.myEntityRepository.delete(ids);
|
|
}
|
|
|
|
protected async executeForWorkspaceSchema(): Promise<void> {
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Delete-specific helper**:
|
|
- `transpileUniversalDeleteActionToFlatDeleteAction`: Base class helper that handles standard delete transpilation
|
|
|
|
---
|
|
|
|
## Step 4: Universal-to-Flat Conversion
|
|
|
|
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util.ts`
|
|
|
|
```typescript
|
|
import { resolveUniversalRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util';
|
|
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
|
|
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
|
import { type AllFlatEntityMapsByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps-by-metadata-name.type';
|
|
|
|
export const fromUniversalFlatMyEntityToFlatMyEntity = (
|
|
universalFlatMyEntity: UniversalFlatMyEntity,
|
|
flatEntityMaps: AllFlatEntityMapsByMetadataName,
|
|
): FlatMyEntity => {
|
|
// Resolve universal foreign keys back to regular IDs
|
|
return resolveUniversalRelationIdentifiersToIds({
|
|
metadataName: 'myEntity',
|
|
universalFlatEntity: universalFlatMyEntity,
|
|
flatEntityMaps,
|
|
}) as FlatMyEntity;
|
|
};
|
|
```
|
|
|
|
**Key utility**:
|
|
- `resolveUniversalRelationIdentifiersToIds`: Maps universal identifiers → regular IDs (reverse of `resolveEntityRelationUniversalIdentifiers`)
|
|
|
|
---
|
|
|
|
## Action Handler Patterns
|
|
|
|
### Pattern: Create Handler
|
|
```typescript
|
|
// 1. Transpile: Universal → Flat
|
|
protected transpileUniversalActionToFlatAction(
|
|
universalAction,
|
|
flatEntityMaps,
|
|
) {
|
|
return {
|
|
type: 'create',
|
|
metadataName: 'myEntity',
|
|
flatEntity: fromUniversalFlatMyEntityToFlatMyEntity(
|
|
universalAction.universalFlatEntity,
|
|
flatEntityMaps,
|
|
),
|
|
};
|
|
}
|
|
|
|
// 2. Execute: Flat → Database
|
|
protected async executeForMetadata(flatActions) {
|
|
await this.insertFlatEntitiesInRepository({
|
|
repository: this.myEntityRepository,
|
|
flatEntities: flatActions.map(a => a.flatEntity),
|
|
});
|
|
}
|
|
```
|
|
|
|
### Pattern: Update Handler
|
|
```typescript
|
|
// Transpile with update-specific resolution
|
|
protected transpileUniversalActionToFlatAction(
|
|
universalAction,
|
|
flatEntityMaps,
|
|
) {
|
|
const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity(
|
|
universalAction.universalFlatEntity,
|
|
flatEntityMaps,
|
|
);
|
|
|
|
const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({
|
|
metadataName: 'myEntity',
|
|
universalUpdates: universalAction.universalUpdates,
|
|
flatEntityMaps,
|
|
});
|
|
|
|
return { type: 'update', metadataName: 'myEntity', flatEntity, updates: flatUpdates };
|
|
}
|
|
```
|
|
|
|
### Pattern: Delete Handler
|
|
```typescript
|
|
// Use base class helper
|
|
protected transpileUniversalActionToFlatAction(
|
|
universalAction,
|
|
flatEntityMaps,
|
|
) {
|
|
return this.transpileUniversalDeleteActionToFlatDeleteAction({
|
|
universalAction,
|
|
flatEntityMaps,
|
|
fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity,
|
|
});
|
|
}
|
|
|
|
// Delete
|
|
protected async executeForMetadata(flatActions) {
|
|
const ids = flatActions.map(a => a.flatEntity.id);
|
|
await this.myEntityRepository.delete(ids);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist
|
|
|
|
Before moving to Step 5:
|
|
|
|
- [ ] Create action handler implemented
|
|
- [ ] Update action handler implemented
|
|
- [ ] Delete action handler implemented
|
|
- [ ] All handlers extend appropriate base class
|
|
- [ ] `transpileUniversalActionToFlatAction` implemented in all handlers
|
|
- [ ] `executeForMetadata` implemented in all handlers
|
|
- [ ] `executeForWorkspaceSchema` implemented (or returns empty)
|
|
- [ ] Universal-to-flat conversion utility created
|
|
- [ ] Create handler uses `insertFlatEntitiesInRepository`
|
|
- [ ] Update handler uses `resolveUniversalUpdateRelationIdentifiersToIds`
|
|
- [ ] Delete handler uses `transpileUniversalDeleteActionToFlatDeleteAction`
|
|
- [ ] Delete handler uses hard delete (`delete()`)
|
|
|
|
---
|
|
|
|
## Next Step
|
|
|
|
Once action handlers are complete, proceed to:
|
|
**[Syncable Entity: Integration (Step 5/6)](../syncable-entity-integration/SKILL.md)**
|
|
|
|
For complete workflow, see `@creating-syncable-entity` rule.
|