feat: refactor folder structure (#4498)

* feat: wip refactor folder structure

* Fix

* fix position

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2024-03-15 14:40:58 +01:00
committed by GitHub
parent 52f1b3ac98
commit 94487f6737
760 changed files with 3215 additions and 3155 deletions

View File

@ -0,0 +1,153 @@
import { Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { Command, CommandRunner } from 'nest-commander';
import { DataSource } from 'typeorm';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { StandardObjectFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-object.factory';
import { computeStandardObject } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/compute-standard-object.util';
import { StandardFieldFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory';
import { CustomObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/custom-objects/custom.object-metadata';
@Command({
name: 'workspace:add-standard-id',
description: 'Add standard id to all metadata objects and fields',
})
export class AddStandardIdCommand extends CommandRunner {
private readonly logger = new Logger(AddStandardIdCommand.name);
constructor(
@InjectDataSource('metadata')
private readonly metadataDataSource: DataSource,
private readonly standardObjectFactory: StandardObjectFactory,
private readonly standardFieldFactory: StandardFieldFactory,
) {
super();
}
async run(): Promise<void> {
const queryRunner = this.metadataDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
const manager = queryRunner.manager;
this.logger.log('Adding standardId to metadata objects and fields');
try {
const standardObjectMetadataCollection =
this.standardObjectFactory.create(
standardObjectMetadataDefinitions,
{
// We don't need to provide the workspace id and data source id as we're only adding standardId
workspaceId: '',
dataSourceId: '',
},
{
IS_BLOCKLIST_ENABLED: true,
IS_CALENDAR_ENABLED: true,
},
);
const standardFieldMetadataCollection = this.standardFieldFactory.create(
CustomObjectMetadata,
{
workspaceId: '',
dataSourceId: '',
},
{
IS_BLOCKLIST_ENABLED: true,
IS_CALENDAR_ENABLED: true,
},
);
const objectMetadataRepository =
manager.getRepository(ObjectMetadataEntity);
const fieldMetadataRepository =
manager.getRepository(FieldMetadataEntity);
/**
* Update all object metadata with standard id
*/
const updateObjectMetadataCollection: Partial<ObjectMetadataEntity>[] =
[];
const updateFieldMetadataCollection: Partial<FieldMetadataEntity>[] = [];
const originalObjectMetadataCollection =
await objectMetadataRepository.find({
where: {
fields: { isCustom: false },
},
relations: ['fields'],
});
const customObjectMetadataCollection =
originalObjectMetadataCollection.filter(
(metadata) => metadata.isCustom,
);
const standardObjectMetadataMap = new Map(
standardObjectMetadataCollection.map((metadata) => [
metadata.nameSingular,
metadata,
]),
);
for (const originalObjectMetadata of originalObjectMetadataCollection) {
const standardObjectMetadata = standardObjectMetadataMap.get(
originalObjectMetadata.nameSingular,
);
if (!standardObjectMetadata && !originalObjectMetadata.isCustom) {
continue;
}
const computedStandardObjectMetadata = computeStandardObject(
standardObjectMetadata ?? {
...originalObjectMetadata,
fields: standardFieldMetadataCollection,
},
originalObjectMetadata,
customObjectMetadataCollection,
);
if (
!originalObjectMetadata.isCustom &&
!originalObjectMetadata.standardId
) {
updateObjectMetadataCollection.push({
id: originalObjectMetadata.id,
standardId: computedStandardObjectMetadata.standardId,
});
}
for (const fieldMetadata of originalObjectMetadata.fields) {
const standardFieldMetadata =
computedStandardObjectMetadata.fields.find(
(field) => field.name === fieldMetadata.name && !field.isCustom,
);
if (!standardFieldMetadata || fieldMetadata.standardId) {
continue;
}
updateFieldMetadataCollection.push({
id: fieldMetadata.id,
standardId: standardFieldMetadata.standardId,
});
}
}
await objectMetadataRepository.save(updateObjectMetadataCollection);
await fieldMetadataRepository.save(updateFieldMetadataCollection);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error('Error adding standard id to metadata', error);
} finally {
await queryRunner.release();
}
}
}

View File

@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { CommandLogger } from 'src/commands/command-logger';
@Injectable()
export class SyncWorkspaceLoggerService {
private readonly commandLogger = new CommandLogger(
SyncWorkspaceLoggerService.name,
);
constructor() {}
async saveLogs(
workspaceId: string,
storage: WorkspaceSyncStorage,
workspaceMigrations: WorkspaceMigrationEntity[],
) {
// Create sub directory
await this.commandLogger.createSubDirectory(workspaceId);
// Save workspace migrations
await this.commandLogger.writeLog(
`${workspaceId}/workspace-migrations`,
workspaceMigrations,
);
// Save object metadata create collection
await this.commandLogger.writeLog(
`${workspaceId}/object-metadata-create-collection`,
storage.objectMetadataCreateCollection,
);
// Save object metadata update collection
await this.commandLogger.writeLog(
`${workspaceId}/object-metadata-update-collection`,
storage.objectMetadataUpdateCollection,
);
// Save object metadata delete collection
await this.commandLogger.writeLog(
`${workspaceId}/object-metadata-delete-collection`,
storage.objectMetadataDeleteCollection,
);
// Save field metadata create collection
await this.commandLogger.writeLog(
`${workspaceId}/field-metadata-create-collection`,
storage.fieldMetadataCreateCollection,
);
// Save field metadata update collection
await this.commandLogger.writeLog(
`${workspaceId}/field-metadata-update-collection`,
storage.fieldMetadataUpdateCollection,
);
// Save field metadata delete collection
await this.commandLogger.writeLog(
`${workspaceId}/field-metadata-delete-collection`,
storage.fieldMetadataDeleteCollection,
);
// Save relation metadata create collection
await this.commandLogger.writeLog(
`${workspaceId}/relation-metadata-create-collection`,
storage.relationMetadataCreateCollection,
);
// Save relation metadata update collection
await this.commandLogger.writeLog(
`${workspaceId}/relation-metadata-update-collection`,
storage.relationMetadataUpdateCollection,
);
// Save relation metadata delete collection
await this.commandLogger.writeLog(
`${workspaceId}/relation-metadata-delete-collection`,
storage.relationMetadataDeleteCollection,
);
}
}

View File

@ -0,0 +1,119 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service';
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
import { WorkspaceService } from 'src/engine/modules/workspace/services/workspace.service';
import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service';
// TODO: implement dry-run
interface RunWorkspaceMigrationsOptions {
workspaceId?: string;
dryRun?: boolean;
force?: boolean;
}
@Command({
name: 'workspace:sync-metadata',
description: 'Sync metadata',
})
export class SyncWorkspaceMetadataCommand extends CommandRunner {
private readonly logger = new Logger(SyncWorkspaceMetadataCommand.name);
constructor(
private readonly workspaceSyncMetadataService: WorkspaceSyncMetadataService,
private readonly workspaceHealthService: WorkspaceHealthService,
private readonly dataSourceService: DataSourceService,
private readonly syncWorkspaceLoggerService: SyncWorkspaceLoggerService,
private readonly workspaceService: WorkspaceService,
) {
super();
}
async run(
_passedParam: string[],
options: RunWorkspaceMigrationsOptions,
): Promise<void> {
const workspaceIds = options.workspaceId
? [options.workspaceId]
: await this.workspaceService.getWorkspaceIds();
for (const workspaceId of workspaceIds) {
const issues = await this.workspaceHealthService.healthCheck(workspaceId);
// Security: abort if there are issues.
if (issues.length > 0) {
if (!options.force) {
this.logger.error(
`Workspace contains ${issues.length} issues, aborting.`,
);
this.logger.log(
'If you want to force the migration, use --force flag',
);
this.logger.log(
'Please use `workspace:health` command to check issues and fix them before running this command.',
);
return;
}
this.logger.warn(
`Workspace contains ${issues.length} issues, sync has been forced.`,
);
}
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
const { storage, workspaceMigrations } =
await this.workspaceSyncMetadataService.synchronize(
{
workspaceId,
dataSourceId: dataSourceMetadata.id,
},
{ applyChanges: !options.dryRun },
);
if (options.dryRun) {
await this.syncWorkspaceLoggerService.saveLogs(
workspaceId,
storage,
workspaceMigrations,
);
}
}
}
@Option({
flags: '-w, --workspace-id [workspace_id]',
description: 'workspace id',
required: false,
})
parseWorkspaceId(value: string): string {
return value;
}
@Option({
flags: '-d, --dry-run',
description: 'Dry run without applying changes',
required: false,
})
dryRun(): boolean {
return true;
}
@Option({
flags: '-f, --force',
description: 'Force migration',
required: false,
})
force(): boolean {
return true;
}
}

View File

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/engine-metadata/data-source/data-source.module';
import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module';
import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module';
import { WorkspaceModule } from 'src/engine/modules/workspace/workspace.module';
import { AddStandardIdCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command';
import { SyncWorkspaceMetadataCommand } from './sync-workspace-metadata.command';
import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service';
@Module({
imports: [
WorkspaceSyncMetadataModule,
WorkspaceHealthModule,
WorkspaceModule,
DataSourceModule,
],
providers: [
SyncWorkspaceMetadataCommand,
AddStandardIdCommand,
SyncWorkspaceLoggerService,
],
})
export class WorkspaceSyncMetadataCommandsModule {}

View File

@ -0,0 +1,116 @@
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { WorkspaceFieldComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator';
describe('WorkspaceFieldComparator', () => {
let comparator: WorkspaceFieldComparator;
beforeEach(() => {
// Initialize the comparator before each test
comparator = new WorkspaceFieldComparator();
});
function createMockFieldMetadata(values: any) {
return {
workspaceId: 'some-workspace-id',
type: 'TEXT',
name: 'DefaultFieldName',
label: 'Default Field Label',
targetColumnMap: 'default_column',
defaultValue: null,
description: 'Default description',
isCustom: false,
isSystem: false,
isNullable: true,
...values,
};
}
it('should generate CREATE action for new fields', () => {
const original = { fields: [] } as any;
const standard = {
fields: [
createMockFieldMetadata({
standardId: 'no-field-1',
name: 'New Field',
}),
],
} as any;
const result = comparator.compare(original, standard);
expect(result).toEqual([
{
action: ComparatorAction.CREATE,
object: expect.objectContaining(standard.fields[0]),
},
]);
});
it('should generate UPDATE action for modified fields', () => {
const original = {
fields: [
createMockFieldMetadata({
standardId: '1',
id: '1',
isNullable: true,
}),
],
} as any;
const standard = {
fields: [
createMockFieldMetadata({
standardId: '1',
isNullable: false,
}),
],
} as any;
const result = comparator.compare(original, standard);
expect(result).toEqual([
{
action: ComparatorAction.UPDATE,
object: expect.objectContaining({ id: '1', isNullable: false }),
},
]);
});
it('should generate DELETE action for removed fields', () => {
const original = {
fields: [
createMockFieldMetadata({
standardId: '1',
id: '1',
name: 'Removed Field',
isActive: true,
}),
],
} as any;
const standard = { fields: [] } as any;
const result = comparator.compare(original, standard);
expect(result).toEqual([
{
action: ComparatorAction.DELETE,
object: expect.objectContaining({ name: 'Removed Field' }),
},
]);
});
it('should not generate any action for identical fields', () => {
const original = {
fields: [
createMockFieldMetadata({ standardId: '1', id: '1', isActive: true }),
],
} as any;
const standard = {
fields: [createMockFieldMetadata({ standardId: '1' })],
} as any;
const result = comparator.compare(original, standard);
expect(result).toHaveLength(0);
});
});

View File

@ -0,0 +1,82 @@
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { WorkspaceObjectComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-object.comparator';
describe('WorkspaceObjectComparator', () => {
let comparator: WorkspaceObjectComparator;
beforeEach(() => {
// Initialize the comparator before each test
comparator = new WorkspaceObjectComparator();
});
function createMockObjectMetadata(values: any) {
return {
nameSingular: 'TestObject',
namePlural: 'TestObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
...values,
};
}
it('should generate CREATE action for new objects', () => {
const standardObjectMetadata = createMockObjectMetadata({
standardId: 'no-object-1',
description: 'A standard object',
});
const result = comparator.compare(undefined, standardObjectMetadata);
expect(result).toEqual({
action: ComparatorAction.CREATE,
object: standardObjectMetadata,
});
});
it('should generate UPDATE action for objects with differences', () => {
const originalObjectMetadata = createMockObjectMetadata({
standardId: '1',
id: '1',
description: 'Original description',
});
const standardObjectMetadata = createMockObjectMetadata({
standardId: '1',
description: 'Updated description',
});
const result = comparator.compare(
originalObjectMetadata,
standardObjectMetadata,
);
expect(result).toEqual({
action: ComparatorAction.UPDATE,
object: expect.objectContaining({
id: '1',
description: 'Updated description',
}),
});
});
it('should generate SKIP action for identical objects', () => {
const originalObjectMetadata = createMockObjectMetadata({
standardId: '1',
id: '1',
description: 'Same description',
});
const standardObjectMetadata = createMockObjectMetadata({
standardId: '1',
description: 'Same description',
});
const result = comparator.compare(
originalObjectMetadata,
standardObjectMetadata,
);
expect(result).toEqual({
action: ComparatorAction.SKIP,
});
});
});

View File

@ -0,0 +1,82 @@
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { WorkspaceRelationComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-relation.comparator';
describe('WorkspaceRelationComparator', () => {
let comparator: WorkspaceRelationComparator;
beforeEach(() => {
comparator = new WorkspaceRelationComparator();
});
function createMockRelationMetadata(values: any) {
return {
fromObjectMetadataId: 'object-1',
fromFieldMetadataId: 'field-1',
...values,
};
}
it('should generate CREATE action for new relations', () => {
const original = [];
const standard = [createMockRelationMetadata({})];
const result = comparator.compare(original, standard);
expect(result).toEqual([
{
action: ComparatorAction.CREATE,
object: expect.objectContaining({
fromObjectMetadataId: 'object-1',
fromFieldMetadataId: 'field-1',
}),
},
]);
});
it('should generate DELETE action for removed relations', () => {
const original = [createMockRelationMetadata({ id: '1' })];
const standard = [];
const result = comparator.compare(original, standard);
expect(result).toEqual([
{
action: ComparatorAction.DELETE,
object: expect.objectContaining({ id: '1' }),
},
]);
});
it('should generate UPDATE action for changed relations', () => {
const original = [
createMockRelationMetadata({ onDeleteAction: 'CASCADE' }),
];
const standard = [
createMockRelationMetadata({ onDeleteAction: 'SET_NULL' }),
];
const result = comparator.compare(original, standard);
expect(result).toEqual([
{
action: ComparatorAction.UPDATE,
object: expect.objectContaining({
fromObjectMetadataId: 'object-1',
fromFieldMetadataId: 'field-1',
onDeleteAction: 'SET_NULL',
}),
},
]);
});
it('should not generate any action for identical relations', () => {
const relation = createMockRelationMetadata({});
const original = [{ id: '1', ...relation }];
const standard = [relation];
const result = comparator.compare(original, standard);
expect(result).toHaveLength(0);
});
});

View File

@ -0,0 +1,9 @@
import { WorkspaceFieldComparator } from './workspace-field.comparator';
import { WorkspaceObjectComparator } from './workspace-object.comparator';
import { WorkspaceRelationComparator } from './workspace-relation.comparator';
export const workspaceSyncMetadataComparators = [
WorkspaceFieldComparator,
WorkspaceObjectComparator,
WorkspaceRelationComparator,
];

View File

@ -0,0 +1,52 @@
import { orderObjectProperties } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/order-object-properties.util';
describe('orderObjectProperties', () => {
it('orders simple object properties', () => {
const input = { b: 2, a: 1 };
const expected = { a: 1, b: 2 };
expect(orderObjectProperties(input)).toEqual(expected);
});
it('orders nested object properties', () => {
const input = { b: { d: 4, c: 3 }, a: 1 };
const expected = { a: 1, b: { c: 3, d: 4 } };
expect(orderObjectProperties(input)).toEqual(expected);
});
it('orders properties in an array of objects', () => {
const input = [
{ b: 2, a: 1 },
{ d: 4, c: 3 },
];
const expected = [
{ a: 1, b: 2 },
{ c: 3, d: 4 },
];
expect(orderObjectProperties(input)).toEqual(expected);
});
it('handles nested arrays within objects', () => {
const input = { b: [{ d: 4, c: 3 }], a: 1 };
const expected = { a: 1, b: [{ c: 3, d: 4 }] };
expect(orderObjectProperties(input)).toEqual(expected);
});
it('handles complex nested structures', () => {
const input = {
c: 3,
a: { f: [{ j: 10, i: 9 }, 8], e: 5 },
b: [7, { h: 6, g: 4 }],
};
const expected = {
a: { e: 5, f: [{ i: 9, j: 10 }, 8] },
b: [7, { g: 4, h: 6 }],
c: 3,
};
expect(orderObjectProperties(input)).toEqual(expected);
});
});

View File

@ -0,0 +1,59 @@
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; // Adjust the import path as necessary
describe('transformMetadataForComparison', () => {
// Test for a single object
it('transforms a single object correctly with nested objects', () => {
const input = { name: 'Test', details: { a: 1, nested: { b: 2 } } };
const result = transformMetadataForComparison(input, {
propertiesToStringify: ['details'],
});
expect(result).toEqual({
name: 'Test',
details: '{"a":1,"nested":{"b":2}}',
});
});
// Test for an array of objects
it('transforms an array of objects correctly, ignoring and stringifying multiple properties', () => {
const input = [
{ name: 'Test1', value: { a: 1 }, ignored: 'ignoreMe' },
{ name: 'Test2', value: { c: 3 }, extra: 'keepMe' },
];
const result = transformMetadataForComparison(input, {
shouldIgnoreProperty: (property) => ['ignored'].includes(property),
propertiesToStringify: ['value'],
keyFactory: (datum) => datum.name,
});
expect(result).toEqual({
Test1: { name: 'Test1', value: '{"a":1}' },
Test2: { name: 'Test2', value: '{"c":3}', extra: 'keepMe' },
});
});
// Test with a custom keyFactory function
it('uses a custom keyFactory function to generate keys', () => {
const input = [{ id: 123, name: 'Test' }];
const result = transformMetadataForComparison(input, {
keyFactory: (datum) => `key-${datum.id}`,
});
expect(result).toHaveProperty('key-123');
expect(result['key-123']).toEqual({ id: 123, name: 'Test' });
});
// Test with an empty array
it('handles an empty array gracefully', () => {
const result = transformMetadataForComparison([], {});
expect(result).toEqual({});
});
// Test with an empty object
it('handles an empty object gracefully', () => {
const result = transformMetadataForComparison({}, {});
expect(result).toEqual({});
});
});

View File

@ -0,0 +1,21 @@
export function orderObjectProperties<T extends object>(data: T[]): T[];
export function orderObjectProperties<T extends object>(data: T): T;
export function orderObjectProperties<T extends Array<any> | object>(
data: T,
): T {
if (Array.isArray(data)) {
return data.map(orderObjectProperties) as T;
}
if (data !== null && typeof data === 'object') {
return Object.fromEntries(
Object.entries(data)
.sort()
.map(([key, value]) => [key, orderObjectProperties(value)]),
) as T;
}
return data;
}

View File

@ -0,0 +1,83 @@
import { orderObjectProperties } from './order-object-properties.util';
type TransformToString<T, Keys extends keyof T> = {
[P in keyof T]: P extends Keys ? string : T[P];
};
// Overload for an array of T
export function transformMetadataForComparison<T, Keys extends keyof T>(
fieldMetadataCollection: T[],
options: {
shouldIgnoreProperty?: (property: string, originalMetadata?: T) => boolean;
propertiesToStringify?: readonly Keys[];
keyFactory: (datum: T) => string;
},
): Record<string, TransformToString<T, Keys>>;
// Overload for a single T object
export function transformMetadataForComparison<T, Keys extends keyof T>(
fieldMetadataCollection: T,
options: {
shouldIgnoreProperty?: (property: string, originalMetadata?: T) => boolean;
propertiesToStringify?: readonly Keys[];
},
): TransformToString<T, Keys>;
export function transformMetadataForComparison<T, Keys extends keyof T>(
metadata: T[] | T,
options: {
shouldIgnoreProperty?: (property: string, originalMetadata?: T) => boolean;
propertiesToStringify?: readonly Keys[];
keyFactory?: (datum: T) => string;
},
): Record<string, TransformToString<T, Keys>> | TransformToString<T, Keys> {
const propertiesToStringify = (options.propertiesToStringify ??
[]) as readonly string[];
const transformProperties = (datum: T): TransformToString<T, Keys> => {
const transformedField = {} as TransformToString<T, Keys>;
for (const property in datum) {
if (
options.shouldIgnoreProperty &&
options.shouldIgnoreProperty(property, datum)
) {
continue;
}
if (
propertiesToStringify.includes(property) &&
datum[property] !== null &&
typeof datum[property] === 'object'
) {
const orderedValue = orderObjectProperties(datum[property] as object);
transformedField[property as string] = JSON.stringify(
orderedValue,
) as T[Keys];
} else {
transformedField[property as string] = datum[property];
}
}
return transformedField;
};
if (Array.isArray(metadata)) {
return metadata.reduce<Record<string, TransformToString<T, Keys>>>(
(acc, datum) => {
const key = options.keyFactory?.(datum);
if (!key) {
throw new Error('keyFactory must be implemented');
}
acc[key] = transformProperties(datum);
return acc;
},
{},
);
} else {
return transformProperties(metadata);
}
}

View File

@ -0,0 +1,209 @@
import { Injectable } from '@nestjs/common';
import diff from 'microdiff';
import {
ComparatorAction,
FieldComparatorResult,
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { ComputedPartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { ComputedPartialObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
const commonFieldPropertiesToIgnore = [
'id',
'createdAt',
'updatedAt',
'objectMetadataId',
'isActive',
'options',
];
const fieldPropertiesToStringify = ['targetColumnMap', 'defaultValue'] as const;
@Injectable()
export class WorkspaceFieldComparator {
constructor() {}
public compare(
originalObjectMetadata: ObjectMetadataEntity,
standardObjectMetadata: ComputedPartialObjectMetadata,
): FieldComparatorResult[] {
const result: FieldComparatorResult[] = [];
const fieldPropertiesToUpdateMap: Record<
string,
Partial<ComputedPartialFieldMetadata>
> = {};
// Double security to only compare non-custom fields
const filteredOriginalFieldCollection =
originalObjectMetadata.fields.filter((field) => !field.isCustom);
const originalFieldMetadataMap = transformMetadataForComparison(
filteredOriginalFieldCollection,
{
shouldIgnoreProperty: (property, originalMetadata) => {
if (commonFieldPropertiesToIgnore.includes(property)) {
return true;
}
if (
originalMetadata &&
property === 'defaultValue' &&
originalMetadata.type === FieldMetadataType.SELECT
) {
return true;
}
return false;
},
propertiesToStringify: fieldPropertiesToStringify,
keyFactory(datum) {
// Happen when the field is custom
return datum.standardId || datum.name;
},
},
);
const standardFieldMetadataMap = transformMetadataForComparison(
standardObjectMetadata.fields,
{
shouldIgnoreProperty: (property, originalMetadata) => {
if (['options', 'gate'].includes(property)) {
return true;
}
if (
originalMetadata &&
property === 'defaultValue' &&
originalMetadata.type === FieldMetadataType.SELECT
) {
return true;
}
return false;
},
propertiesToStringify: fieldPropertiesToStringify,
keyFactory(datum) {
// Happen when the field is custom
return datum.standardId || datum.name;
},
},
);
// Compare fields
const fieldMetadataDifference = diff(
originalFieldMetadataMap,
standardFieldMetadataMap,
);
for (const difference of fieldMetadataDifference) {
const fieldName = difference.path[0];
const findField = (
field: ComputedPartialFieldMetadata | FieldMetadataEntity,
) => {
if (field.isCustom) {
return field.name === fieldName;
}
return field.standardId === fieldName;
};
// Object shouldn't have thousands of fields, so we can use find here
const standardFieldMetadata =
standardObjectMetadata.fields.find(findField);
const originalFieldMetadata =
originalObjectMetadata.fields.find(findField);
switch (difference.type) {
case 'CREATE': {
if (!standardFieldMetadata) {
throw new Error(
`Field ${fieldName} not found in standardObjectMetadata`,
);
}
result.push({
action: ComparatorAction.CREATE,
object: {
...standardFieldMetadata,
objectMetadataId: originalObjectMetadata.id,
},
});
break;
}
case 'CHANGE': {
if (!originalFieldMetadata) {
throw new Error(
`Field ${fieldName} not found in originalObjectMetadata`,
);
}
const id = originalFieldMetadata.id;
const property = difference.path[difference.path.length - 1];
// If the old value and the new value are both null, skip
// Database is storing null, and we can get undefined here
if (
difference.oldValue === null &&
(difference.value === null || difference.value === undefined)
) {
break;
}
if (typeof property !== 'string') {
break;
}
if (!fieldPropertiesToUpdateMap[id]) {
fieldPropertiesToUpdateMap[id] = {};
}
// If the property is a stringified JSON, parse it
if (
(fieldPropertiesToStringify as readonly string[]).includes(property)
) {
fieldPropertiesToUpdateMap[id][property] = JSON.parse(
difference.value,
);
} else {
fieldPropertiesToUpdateMap[id][property] = difference.value;
}
break;
}
case 'REMOVE': {
if (!originalFieldMetadata) {
throw new Error(
`Field ${fieldName} not found in originalObjectMetadata`,
);
}
if (difference.path.length === 1) {
result.push({
action: ComparatorAction.DELETE,
object: originalFieldMetadata,
});
}
break;
}
}
}
for (const [id, fieldPropertiesToUpdate] of Object.entries(
fieldPropertiesToUpdateMap,
)) {
result.push({
action: ComparatorAction.UPDATE,
object: {
id,
...fieldPropertiesToUpdate,
},
});
}
return result;
}
}

View File

@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import diff from 'microdiff';
import omit from 'lodash.omit';
import {
ComparatorAction,
ObjectComparatorResult,
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { ComputedPartialObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
const objectPropertiesToIgnore = [
'id',
'createdAt',
'updatedAt',
'labelIdentifierFieldMetadataId',
'imageIdentifierFieldMetadataId',
'isActive',
'fields',
];
@Injectable()
export class WorkspaceObjectComparator {
constructor() {}
public compare(
originalObjectMetadata: ObjectMetadataEntity | undefined,
standardObjectMetadata: ComputedPartialObjectMetadata,
): ObjectComparatorResult {
// If the object doesn't exist in the original metadata, we need to create it
if (!originalObjectMetadata) {
return {
action: ComparatorAction.CREATE,
object: standardObjectMetadata,
};
}
const objectPropertiesToUpdate: Partial<ComputedPartialObjectMetadata> = {};
// Only compare properties that are not ignored
const partialOriginalObjectMetadata = transformMetadataForComparison(
originalObjectMetadata,
{
shouldIgnoreProperty: (property) =>
objectPropertiesToIgnore.includes(property),
},
);
// Compare objects
const objectMetadataDifference = diff(
partialOriginalObjectMetadata,
omit(standardObjectMetadata, 'fields'),
);
// Loop through the differences and create an object with the properties to update
for (const difference of objectMetadataDifference) {
// We only handle CHANGE here as REMOVE and CREATE are handled earlier.
if (difference.type === 'CHANGE') {
const property = difference.path[0];
objectPropertiesToUpdate[property] = difference.value;
}
}
// If there are no properties to update, the objects are equal
if (Object.keys(objectPropertiesToUpdate).length === 0) {
return {
action: ComparatorAction.SKIP,
};
}
// If there are properties to update, we need to update the object
return {
action: ComparatorAction.UPDATE,
object: {
id: originalObjectMetadata.id,
...objectPropertiesToUpdate,
},
};
}
}

View File

@ -0,0 +1,109 @@
import { Injectable } from '@nestjs/common';
import diff from 'microdiff';
import {
ComparatorAction,
RelationComparatorResult,
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
const relationPropertiesToIgnore = ['createdAt', 'updatedAt'];
const relationPropertiesToUpdate = ['onDeleteAction'];
@Injectable()
export class WorkspaceRelationComparator {
constructor() {}
compare(
originalRelationMetadataCollection: RelationMetadataEntity[],
standardRelationMetadataCollection: Partial<RelationMetadataEntity>[],
): RelationComparatorResult[] {
const results: RelationComparatorResult[] = [];
// Create a map of standard relations
const standardRelationMetadataMap = transformMetadataForComparison(
standardRelationMetadataCollection,
{
keyFactory(relationMetadata) {
return `${relationMetadata.fromObjectMetadataId}->${relationMetadata.fromFieldMetadataId}`;
},
},
);
// Create a filtered map of original relations
// We filter out 'id' later because we need it to remove the relation from DB
const originalRelationMetadataMap = transformMetadataForComparison(
originalRelationMetadataCollection,
{
shouldIgnoreProperty: (property) =>
relationPropertiesToIgnore.includes(property),
keyFactory(relationMetadata) {
return `${relationMetadata.fromObjectMetadataId}->${relationMetadata.fromFieldMetadataId}`;
},
},
);
// Compare relations
const relationMetadataDifference = diff(
originalRelationMetadataMap,
standardRelationMetadataMap,
);
for (const difference of relationMetadataDifference) {
switch (difference.type) {
case 'CREATE':
results.push({
action: ComparatorAction.CREATE,
object: difference.value,
});
break;
case 'REMOVE':
if (difference.path[difference.path.length - 1] !== 'id') {
results.push({
action: ComparatorAction.DELETE,
object: difference.oldValue,
});
}
break;
case 'CHANGE':
const fieldName = difference.path[0];
const property = difference.path[difference.path.length - 1];
if (!relationPropertiesToUpdate.includes(property as string)) {
continue;
}
const originalRelationMetadata =
originalRelationMetadataMap[fieldName];
if (!originalRelationMetadata) {
throw new Error(
`Relation ${fieldName} not found in originalRelationMetadataMap`,
);
}
results.push({
action: ComparatorAction.UPDATE,
object: {
id: originalRelationMetadata.id,
fromObjectMetadataId:
originalRelationMetadata.fromObjectMetadataId,
fromFieldMetadataId: originalRelationMetadata.fromFieldMetadataId,
toObjectMetadataId: originalRelationMetadata.toObjectMetadataId,
toFieldMetadataId: originalRelationMetadata.toFieldMetadataId,
workspaceId: originalRelationMetadata.workspaceId,
...{
[property]: difference.value,
},
},
});
break;
}
}
return results;
}
}

View File

@ -0,0 +1,295 @@
/**
* /!\ DO NOT EDIT THE IDS OF THIS FILE /!\
* This file contains static ids for standard objects.
* These ids are used to identify standard objects in the database and compare them even when renamed.
* For readability keys can be edited but the values should not be changed.
*/
export const activityTargetStandardFieldIds = {
activity: '20202020-ca58-478c-a4f5-ae825671c30e',
person: '20202020-4afd-4ae7-99c2-de57d795a93f',
company: '20202020-7cc0-44a1-8068-f11171fdd02e',
opportunity: '20202020-1fc2-4af1-8c91-7901ee0fd38b',
custom: '20202020-7f21-442f-94be-32462281b1ca',
};
export const activityStandardFieldIds = {
title: '20202020-24a1-4d94-a071-617f3eeed7b0',
body: '20202020-209b-440a-b2a8-043fa36a7d37',
type: '20202020-0f2b-4aab-8827-ee5d3f07d993',
reminderAt: '20202020-eb06-43e2-ba06-336be0e665a3',
dueAt: '20202020-0336-4511-ba79-565b12801bd9',
completedAt: '20202020-0f4d-4fca-9f2f-6309d9ecb85f',
activityTargets: '20202020-7253-42cb-8586-8cf950e70b79',
attachments: '20202020-5547-4197-bc2e-a07dfc4559ca',
comments: '20202020-6b2e-4d29-bbd1-ecddb330e71a',
author: '20202020-455f-44f2-8e89-1b0ef01cb7fb',
assignee: '20202020-4259-48e4-9e77-6b92991906d5',
};
export const apiKeyStandardFieldIds = {
name: '20202020-72e6-4079-815b-436ce8a62f23',
expiresAt: '20202020-659b-4241-af59-66515b8e7d40',
revokedAt: '20202020-06ab-44b5-8faf-f6e407685001',
};
export const attachmentStandardFieldIds = {
name: '20202020-87a5-48f8-bbf7-ade388825a57',
fullPath: '20202020-0d19-453d-8e8d-fbcda8ca3747',
type: '20202020-a417-49b8-a40b-f6a7874caa0d',
author: '20202020-6501-4ac5-a4ef-b2f8522ef6cd',
activity: '20202020-b569-481b-a13f-9b94e47e54fe',
person: '20202020-0158-4aa2-965c-5cdafe21ffa2',
company: '20202020-ceab-4a28-b546-73b06b4c08d5',
opportunity: '20202020-7374-499d-bea3-9354890755b5',
custom: '20202020-302d-43b3-9aea-aa4f89282a9f',
};
export const baseObjectStandardFieldIds = {
id: '20202020-eda0-4cee-9577-3eb357e3c22b',
createdAt: '20202020-66ac-4502-9975-e4d959c50311',
updatedAt: '20202020-d767-4622-bdcf-d8a084834d86',
};
export const blocklistStandardFieldIds = {
handle: '20202020-eef3-44ed-aa32-4641d7fd4a3e',
workspaceMember: '20202020-548d-4084-a947-fa20a39f7c06',
};
export const calendarChannelEventAssociationStandardFieldIds = {
calendarChannel: '20202020-93ee-4da4-8d58-0282c4a9cb7d',
calendarEvent: '20202020-5aa5-437e-bb86-f42d457783e3',
eventExternalId: '20202020-9ec8-48bb-b279-21d0734a75a1',
};
export const calendarChannelStandardFieldIds = {
connectedAccount: '20202020-95b1-4f44-82dc-61b042ae2414',
handle: '20202020-1d08-420a-9aa7-22e0f298232d',
visibility: '20202020-1b07-4796-9f01-d626bab7ca4d',
isContactAutoCreationEnabled: '20202020-50fb-404b-ba28-369911a3793a',
isSyncEnabled: '20202020-fe19-4818-8854-21f7b1b43395',
syncCursor: '20202020-bac2-4852-a5cb-7a7898992b70',
calendarChannelEventAssociations: '20202020-afb0-4a9f-979f-2d5087d71d09',
};
export const calendarEventAttendeeStandardFieldIds = {
calendarEvent: '20202020-fe3a-401c-b889-af4f4657a861',
handle: '20202020-8692-4580-8210-9e09cbd031a7',
displayName: '20202020-ee1e-4f9f-8ac1-5c0b2f69691e',
isOrganizer: '20202020-66e7-4e00-9e06-d06c92650580',
responseStatus: '20202020-cec0-4be8-8fba-c366abc23147',
person: '20202020-5761-4842-8186-e1898ef93966',
workspaceMember: '20202020-20e4-4591-93ed-aeb17a4dcbd2',
};
export const calendarEventStandardFieldIds = {
title: '20202020-080e-49d1-b21d-9702a7e2525c',
isCanceled: '20202020-335b-4e04-b470-43b84b64863c',
isFullDay: '20202020-551c-402c-bb6d-dfe9efe86bcb',
startsAt: '20202020-2c57-4c75-93c5-2ac950a6ed67',
endsAt: '20202020-2554-4ee1-a617-17907f6bab21',
externalCreatedAt: '20202020-9f03-4058-a898-346c62181599',
externalUpdatedAt: '20202020-b355-4c18-8825-ef42c8a5a755',
description: '20202020-52c4-4266-a98f-e90af0b4d271',
location: '20202020-641a-4ffe-960d-c3c186d95b17',
iCalUID: '20202020-f24b-45f4-b6a3-d2f9fcb98714',
conferenceSolution: '20202020-1c3f-4b5a-b526-5411a82179eb',
conferenceUri: '20202020-0fc5-490a-871a-2df8a45ab46c',
recurringEventExternalId: '20202020-4b96-43d0-8156-4c7a9717635c',
calendarChannelEventAssociations: '20202020-bdf8-4572-a2cc-ecbb6bcc3a02',
eventAttendees: '20202020-e07e-4ccb-88f5-6f3d00458eec',
};
export const commentStandardFieldIds = {
body: '20202020-d5eb-49d2-b3e0-1ed04145ebb7',
author: '20202020-2ab1-427e-a981-cf089de3a9bd',
activity: '20202020-c8d9-4c30-a35e-dc7f44388070',
};
export const companyStandardFieldIds = {
name: '20202020-4d99-4e2e-a84c-4a27837b1ece',
domainName: '20202020-0c28-43d8-8ba5-3659924d3489',
address: '20202020-a82a-4ee2-96cc-a18a3259d953',
employees: '20202020-8965-464a-8a75-74bafc152a0b',
linkedinLink: '20202020-ebeb-4beb-b9ad-6848036fb451',
xLink: '20202020-6f64-4fd9-9580-9c1991c7d8c3',
annualRecurringRevenue: '20202020-602a-495c-9776-f5d5b11d227b',
idealCustomerProfile: '20202020-ba6b-438a-8213-2c5ba28d76a2',
position: '20202020-9b4e-462b-991d-a0ee33326454',
people: '20202020-3213-4ddf-9494-6422bcff8d7c',
accountOwner: '20202020-95b8-4e10-9881-edb5d4765f9d',
activityTargets: '20202020-c2a5-4c9b-9d9a-582bcd57fbc8',
opportunities: '20202020-add3-4658-8e23-d70dccb6d0ec',
favorites: '20202020-4d1d-41ac-b13b-621631298d55',
attachments: '20202020-c1b5-4120-b0f0-987ca401ed53',
};
export const connectedAccountStandardFieldIds = {
handle: '20202020-c804-4a50-bb05-b3a9e24f1dec',
provider: '20202020-ebb0-4516-befc-a9e95935efd5',
accessToken: '20202020-707b-4a0a-8753-2ad42efe1e29',
refreshToken: '20202020-532d-48bd-80a5-c4be6e7f6e49',
accountOwner: '20202020-3517-4896-afac-b1d0aa362af6',
lastSyncHistoryId: '20202020-115c-4a87-b50f-ac4367a971b9',
messageChannels: '20202020-24f7-4362-8468-042204d1e445',
calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977',
};
export const favoriteStandardFieldIds = {
position: '20202020-dd26-42c6-8c3c-2a7598c204f6',
workspaceMember: '20202020-ce63-49cb-9676-fdc0c45892cd',
person: '20202020-c428-4f40-b6f3-86091511c41c',
company: '20202020-cff5-4682-8bf9-069169e08279',
opportunity: '20202020-dabc-48e1-8318-2781a2b32aa2',
custom: '20202020-855a-4bc8-9861-79deef37011f',
};
export const messageChannelMessageAssociationStandardFieldIds = {
messageChannel: '20202020-b658-408f-bd46-3bd2d15d7e52',
message: '20202020-da5d-4ac5-8743-342ab0a0336b',
messageExternalId: '20202020-37d6-438f-b6fd-6503596c8f34',
messageThread: '20202020-fac8-42a8-94dd-44dbc920ae16',
messageThreadExternalId: '20202020-35fb-421e-afa0-0b8e8f7f9018',
};
export const messageChannelStandardFieldIds = {
visibility: '20202020-6a6b-4532-9767-cbc61b469453',
handle: '20202020-2c96-43c3-93e3-ed6b1acb69bc',
connectedAccount: '20202020-49a2-44a4-b470-282c0440d15d',
type: '20202020-ae95-42d9-a3f1-797a2ea22122',
isContactAutoCreationEnabled: '20202020-fabd-4f14-b7c6-3310f6d132c6',
messageChannelMessageAssociations: '20202020-49b8-4766-88fd-75f1e21b3d5f',
};
export const messageParticipantStandardFieldIds = {
message: '20202020-985b-429a-9db9-9e55f4898a2a',
role: '20202020-65d1-42f4-8729-c9ec1f52aecd',
handle: '20202020-2456-464e-b422-b965a4db4a0b',
displayName: '20202020-36dd-4a4f-ac02-228425be9fac',
person: '20202020-249d-4e0f-82cd-1b9df5cd3da2',
workspaceMember: '20202020-77a7-4845-99ed-1bcbb478be6f',
};
export const messageThreadStandardFieldIds = {
messages: '20202020-3115-404f-aade-e1154b28e35a',
messageChannelMessageAssociations: '20202020-314e-40a4-906d-a5d5d6c285f6',
};
export const messageStandardFieldIds = {
headerMessageId: '20202020-72b5-416d-aed8-b55609067d01',
messageThread: '20202020-30f2-4ccd-9f5c-e41bb9d26214',
direction: '20202020-0203-4118-8e2a-05b9bdae6dab',
subject: '20202020-52d1-4036-b9ae-84bd722bb37a',
text: '20202020-d2ee-4e7e-89de-9a0a9044a143',
receivedAt: '20202020-140a-4a2a-9f86-f13b6a979afc',
messageParticipants: '20202020-7cff-4a74-b63c-73228448cbd9',
messageChannelMessageAssociations: '20202020-3cef-43a3-82c6-50e7cfbc9ae4',
};
export const opportunityStandardFieldIds = {
name: '20202020-8609-4f65-a2d9-44009eb422b5',
amount: '20202020-583e-4642-8533-db761d5fa82f',
closeDate: '20202020-527e-44d6-b1ac-c4158d307b97',
probability: '20202020-69d4-45f3-9703-690b09fafcf0',
stage: '20202020-6f76-477d-8551-28cd65b2b4b9',
position: '20202020-806d-493a-bbc6-6313e62958e2',
pipelineStep: '20202020-cc8c-4ae7-8d83-25c3addaec5a',
pointOfContact: '20202020-8dfb-42fc-92b6-01afb759ed16',
company: '20202020-cbac-457e-b565-adece5fc815f',
favorites: '20202020-a1c2-4500-aaae-83ba8a0e827a',
activityTargets: '20202020-220a-42d6-8261-b2102d6eab35',
attachments: '20202020-87c7-4118-83d6-2f4031005209',
};
export const personStandardFieldIds = {
name: '20202020-3875-44d5-8c33-a6239011cab8',
email: '20202020-a740-42bb-8849-8980fb3f12e1',
linkedinLink: '20202020-f1af-48f7-893b-2007a73dd508',
xLink: '20202020-8fc2-487c-b84a-55a99b145cfd',
jobTitle: '20202020-b0d0-415a-bef9-640a26dacd9b',
phone: '20202020-4564-4b8b-a09f-05445f2e0bce',
city: '20202020-5243-4ffb-afc5-2c675da41346',
avatarUrl: '20202020-b8a6-40df-961c-373dc5d2ec21',
position: '20202020-fcd5-4231-aff5-fff583eaa0b1',
company: '20202020-e2f3-448e-b34c-2d625f0025fd',
pointOfContactForOpportunities: '20202020-911b-4a7d-b67b-918aa9a5b33a',
activityTargets: '20202020-dee7-4b7f-b50a-1f50bd3be452',
favorites: '20202020-4073-4117-9cf1-203bcdc91cbd',
attachments: '20202020-cd97-451f-87fa-bcb789bdbf3a',
messageParticipants: '20202020-498e-4c61-8158-fa04f0638334',
calendarEventAttendees: '20202020-52ee-45e9-a702-b64b3753e3a9',
};
export const pipelineStepStandardFieldIds = {
name: '20202020-e10a-4119-9466-97873e86fa47',
color: '20202020-4a09-4088-90b8-ce1c72730f43',
position: '20202020-44e8-4520-af64-4a3cb37fa0c5',
opportunities: '20202020-0442-482a-867f-6d8fd4145ed1',
};
export const viewFieldStandardFieldIds = {
fieldMetadataId: '20202020-135f-4c5b-b361-15f24870473c',
isVisible: '20202020-e966-473c-9c18-f00d3347e0ba',
size: '20202020-6fab-4bd0-ae72-20f3ee39d581',
position: '20202020-19e5-4e4c-8c15-3a96d1fd0650',
view: '20202020-e8da-4521-afab-d6d231f9fa18',
};
export const viewFilterStandardFieldIds = {
fieldMetadataId: '20202020-c9aa-4c94-8d0e-9592f5008fb0',
operand: '20202020-bd23-48c4-9fab-29d1ffb80310',
value: '20202020-1e55-4a1e-a1d2-fefb86a5fce5',
displayValue: '20202020-1270-4ebf-9018-c0ec10d5038e',
view: '20202020-4f5b-487e-829c-3d881c163611',
};
export const viewSortStandardFieldIds = {
fieldMetadataId: '20202020-8240-4657-aee4-7f0df8e94eca',
direction: '20202020-b06e-4eb3-9b58-0a62e5d79836',
view: '20202020-bd6c-422b-9167-5c105f2d02c8',
};
export const viewStandardFieldIds = {
name: '20202020-12c6-4f37-b588-c9b9bf57328d',
objectMetadataId: '20202020-d6de-4fd5-84dd-47f9e730368b',
type: '20202020-dd11-4607-9ec7-c57217262a7f',
key: '20202020-298e-49fa-9f4a-7b416b110443',
icon: '20202020-1f08-4fd9-929b-cbc07f317166',
position: '20202020-e9db-4303-b271-e8250c450172',
isCompact: '20202020-674e-4314-994d-05754ea7b22b',
viewFields: '20202020-542b-4bdc-b177-b63175d48edf',
viewFilters: '20202020-ff23-4154-b63c-21fb36cd0967',
viewSorts: '20202020-891b-45c3-9fe1-80a75b4aa043',
};
export const webhookStandardFieldIds = {
targetUrl: '20202020-1229-45a8-8cf4-85c9172aae12',
operation: '20202020-15b7-458e-bf30-74770a54410c',
};
export const workspaceMemberStandardFieldIds = {
name: '20202020-e914-43a6-9c26-3603c59065f4',
colorScheme: '20202020-66bc-47f2-adac-f2ef7c598b63',
locale: '20202020-402e-4695-b169-794fa015afbe',
avatarUrl: '20202020-0ced-4c4f-a376-c98a966af3f6',
userEmail: '20202020-4c5f-4e09-bebc-9e624e21ecf4',
userId: '20202020-75a9-4dfc-bf25-2e4b43e89820',
authoredActivities: '20202020-f139-4f13-a82f-a65a8d290a74',
assignedActivities: '20202020-5c97-42b6-8ca9-c07622cbb33f',
favorites: '20202020-f3c1-4faf-b343-cf7681038757',
accountOwnerForCompanies: '20202020-dc29-4bd4-a3c1-29eafa324bee',
authoredAttachments: '20202020-000f-4947-917f-1b09851024fe',
authoredComments: '20202020-5536-4f59-b837-51c45ef43b05',
connectedAccounts: '20202020-e322-4bde-a525-727079b4a100',
messageParticipants: '20202020-8f99-48bc-a5eb-edd33dd54188',
blocklist: '20202020-6cb2-4161-9f29-a4b7f1283859',
calendarEventAttendees: '20202020-0dbc-4841-9ce1-3e793b5b3512',
};
export const customObjectStandardFieldIds = {
name: '20202020-ba07-4ffd-ba63-009491f5749c',
position: '20202020-c2bd-4e16-bb9a-c8b0411bf49d',
activityTargets: '20202020-7f42-40ae-b96c-c8a61acc83bf',
favorites: '20202020-a4a7-4686-b296-1c6c3482ee21',
attachments: '20202020-8d59-46ca-b7b2-73d167712134',
};

View File

@ -0,0 +1,36 @@
/**
* /!\ DO NOT EDIT THE IDS OF THIS FILE /!\
* This file contains static ids for standard objects.
* These ids are used to identify standard objects in the database and compare them even when renamed.
* For readability keys can be edited but the values should not be changed.
*/
export const standardObjectIds = {
activityTarget: '20202020-2945-440e-8d1a-f84672d33d5e',
activity: '20202020-39aa-4a89-843b-eb5f2a8b677f',
apiKey: '20202020-4c00-401d-8cda-ec6a4c41cd7d',
attachment: '20202020-bd3d-4c60-8dca-571c71d4447a',
blocklist: '20202020-0408-4f38-b8a8-4d5e3e26e24d',
calendarChannelEventAssociation: '20202020-491b-4aaa-9825-afd1bae6ae00',
calendarChannel: '20202020-e8f2-40e1-a39c-c0e0039c5034',
calendarEventAttendee: '20202020-a1c3-47a6-9732-27e5b1e8436d',
calendarEvent: '20202020-8f1d-4eef-9f85-0d1965e27221',
comment: '20202020-435f-4de9-89b5-97e32233bf5f',
company: '20202020-b374-4779-a561-80086cb2e17f',
connectedAccount: '20202020-977e-46b2-890b-c3002ddfd5c5',
favorite: '20202020-ab56-4e05-92a3-e2414a499860',
messageChannelMessageAssociation: '20202020-ad1e-4127-bccb-d83ae04d2ccb',
messageChannel: '20202020-fe8c-40bc-a681-b80b771449b7',
messageParticipant: '20202020-a433-4456-aa2d-fd9cb26b774a',
messageThread: '20202020-849a-4c3e-84f5-a25a7d802271',
message: '20202020-3f6b-4425-80ab-e468899ab4b2',
opportunity: '20202020-9549-49dd-b2b2-883999db8938',
person: '20202020-e674-48e5-a542-72570eee7213',
pipelineStep: '20202020-f9a3-45f3-82e2-28952a8b19bf',
viewField: '20202020-4d19-4655-95bf-b2a04cf206d4',
viewFilter: '20202020-6fb6-4631-aded-b7d67e952ec8',
viewSort: '20202020-e46a-47a8-939a-e5d911f83531',
view: '20202020-722e-4739-8e2c-0c372d661f49',
webhook: '20202020-be4d-4e08-811d-0fffcd13ffd4',
workspaceMember: '20202020-3319-4234-a34c-82d5c0e881a6',
};

View File

@ -0,0 +1,88 @@
import { BaseCustomObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/base-custom-object-metadata.decorator';
import { FieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { IsNullable } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-nullable.decorator';
import { IsSystem } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-system.decorator';
import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata';
import {
RelationMetadataType,
RelationOnDeleteAction,
} from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { ActivityTargetObjectMetadata } from 'src/business/modules/activity/activity-target.object-metadata';
import { RelationMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/relation-metadata.decorator';
import { FavoriteObjectMetadata } from 'src/business/modules/favorite/favorite.object-metadata';
import { AttachmentObjectMetadata } from 'src/business/modules/attachment/attachment.object-metadata';
import { customObjectStandardFieldIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
@BaseCustomObjectMetadata()
export class CustomObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
standardId: customObjectStandardFieldIds.name,
label: 'Name',
description: 'Name',
type: FieldMetadataType.TEXT,
icon: 'IconAbc',
defaultValue: { value: 'Untitled' },
})
name: string;
@FieldMetadata({
standardId: customObjectStandardFieldIds.position,
label: 'Position',
description: 'Position',
type: FieldMetadataType.POSITION,
icon: 'IconHierarchy2',
})
@IsNullable()
@IsSystem()
position: number;
@FieldMetadata({
standardId: customObjectStandardFieldIds.activityTargets,
type: FieldMetadataType.RELATION,
label: 'Activities',
description: (objectMetadata) =>
`Activities tied to the ${objectMetadata.labelSingular}`,
icon: 'IconCheckbox',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
inverseSideTarget: () => ActivityTargetObjectMetadata,
onDelete: RelationOnDeleteAction.CASCADE,
})
@IsNullable()
activityTargets: ActivityTargetObjectMetadata[];
@FieldMetadata({
standardId: customObjectStandardFieldIds.favorites,
type: FieldMetadataType.RELATION,
label: 'Favorites',
description: (objectMetadata) =>
`Favorites tied to the ${objectMetadata.labelSingular}`,
icon: 'IconHeart',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
inverseSideTarget: () => FavoriteObjectMetadata,
onDelete: RelationOnDeleteAction.CASCADE,
})
@IsNullable()
@IsSystem()
favorites: FavoriteObjectMetadata[];
@FieldMetadata({
standardId: customObjectStandardFieldIds.attachments,
type: FieldMetadataType.RELATION,
label: 'Attachments',
description: (objectMetadata) =>
`Attachments tied to the ${objectMetadata.labelSingular}`,
icon: 'IconFileImport',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
inverseSideTarget: () => AttachmentObjectMetadata,
onDelete: RelationOnDeleteAction.CASCADE,
})
@IsNullable()
attachments: AttachmentObjectMetadata[];
}

View File

@ -0,0 +1,20 @@
import { BaseCustomObjectMetadataDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-custom-object-metadata.interface';
import { TypedReflect } from 'src/utils/typed-reflect';
export function BaseCustomObjectMetadata(
params?: BaseCustomObjectMetadataDecoratorParams,
): ClassDecorator {
return (target) => {
const gate = TypedReflect.getMetadata('gate', target);
TypedReflect.defineMetadata(
'extendObjectMetadata',
{
...params,
gate,
},
target,
);
};
}

View File

@ -0,0 +1,27 @@
import { DynamicRelationFieldMetadataDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-computed-relation-field-metadata.interface';
import { TypedReflect } from 'src/utils/typed-reflect';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export function DynamicRelationFieldMetadata(
params: DynamicRelationFieldMetadataDecoratorParams,
): PropertyDecorator {
return (target: object, fieldKey: string) => {
const isSystem =
TypedReflect.getMetadata('isSystem', target, fieldKey) ?? false;
const gate = TypedReflect.getMetadata('gate', target, fieldKey);
TypedReflect.defineMetadata(
'dynamicRelationFieldMetadataMap',
{
type: FieldMetadataType.RELATION,
paramsFactory: params,
isCustom: false,
isNullable: true,
isSystem,
gate,
},
target.constructor,
);
};
}

View File

@ -0,0 +1,92 @@
import {
FieldMetadataDecoratorParams,
ReflectFieldMetadata,
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-field-metadata.interface';
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
import { generateDefaultValue } from 'src/engine-metadata/field-metadata/utils/generate-default-value';
import { TypedReflect } from 'src/utils/typed-reflect';
import { createDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util';
export function FieldMetadata<T extends FieldMetadataType>(
params: FieldMetadataDecoratorParams<T>,
): PropertyDecorator {
return (target: object, fieldKey: string) => {
const existingFieldMetadata =
TypedReflect.getMetadata('fieldMetadataMap', target.constructor) ?? {};
const isNullable =
TypedReflect.getMetadata('isNullable', target, fieldKey) ?? false;
const isSystem =
TypedReflect.getMetadata('isSystem', target, fieldKey) ?? false;
const gate = TypedReflect.getMetadata('gate', target, fieldKey);
const { joinColumn, standardId, ...restParams } = params;
TypedReflect.defineMetadata(
'fieldMetadataMap',
{
...existingFieldMetadata,
[fieldKey]: generateFieldMetadata<T>(
{
...restParams,
standardId,
},
fieldKey,
isNullable,
isSystem,
gate,
),
...(joinColumn && restParams.type === FieldMetadataType.RELATION
? {
[joinColumn]: generateFieldMetadata<FieldMetadataType.UUID>(
{
...restParams,
standardId: createDeterministicUuid(standardId),
type: FieldMetadataType.UUID,
label: `${restParams.label} id (foreign key)`,
description: `${restParams.description} id foreign key`,
defaultValue: null,
options: undefined,
},
joinColumn,
isNullable,
true,
gate,
),
}
: {}),
},
target.constructor,
);
};
}
function generateFieldMetadata<T extends FieldMetadataType>(
params: FieldMetadataDecoratorParams<T>,
fieldKey: string,
isNullable: boolean,
isSystem: boolean,
gate: GateDecoratorParams | undefined = undefined,
): ReflectFieldMetadata[string] {
const targetColumnMap = generateTargetColumnMap(params.type, false, fieldKey);
const defaultValue = (params.defaultValue ??
generateDefaultValue(
params.type,
)) as FieldMetadataDefaultValue<'default'> | null;
return {
name: fieldKey,
...params,
targetColumnMap,
isNullable: params.type === FieldMetadataType.RELATION ? true : isNullable,
isSystem,
isCustom: false,
options: params.options,
description: params.description,
icon: params.icon,
defaultValue,
gate,
};
}

View File

@ -0,0 +1,13 @@
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
import { TypedReflect } from 'src/utils/typed-reflect';
export function Gate(metadata: GateDecoratorParams) {
return function (target: object, fieldKey?: string) {
if (fieldKey) {
TypedReflect.defineMetadata('gate', metadata, target, fieldKey);
} else {
TypedReflect.defineMetadata('gate', metadata, target);
}
};
}

View File

@ -0,0 +1,7 @@
import { TypedReflect } from 'src/utils/typed-reflect';
export function IsNullable() {
return function (target: object, fieldKey: string) {
TypedReflect.defineMetadata('isNullable', true, target, fieldKey);
};
}

View File

@ -0,0 +1,11 @@
import { TypedReflect } from 'src/utils/typed-reflect';
export function IsSystem() {
return function (target: object, fieldKey?: string) {
if (fieldKey) {
TypedReflect.defineMetadata('isSystem', true, target, fieldKey);
} else {
TypedReflect.defineMetadata('isSystem', true, target);
}
};
}

View File

@ -0,0 +1,29 @@
import { ObjectMetadataDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-object-metadata.interface';
import { TypedReflect } from 'src/utils/typed-reflect';
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
export function ObjectMetadata(
params: ObjectMetadataDecoratorParams,
): ClassDecorator {
return (target) => {
const isSystem = TypedReflect.getMetadata('isSystem', target) ?? false;
const gate = TypedReflect.getMetadata('gate', target);
const objectName = convertClassNameToObjectMetadataName(target.name);
TypedReflect.defineMetadata(
'objectMetadata',
{
nameSingular: objectName,
...params,
targetTableName: 'DEPRECATED',
isSystem,
isCustom: false,
description: params.description,
icon: params.icon,
gate,
},
target,
);
};
}

View File

@ -0,0 +1,37 @@
import 'reflect-metadata';
import {
ReflectRelationMetadata,
RelationMetadataDecoratorParams,
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-relation-metadata.interface';
import { TypedReflect } from 'src/utils/typed-reflect';
import { RelationOnDeleteAction } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
export function RelationMetadata<TClass extends object>(
params: RelationMetadataDecoratorParams<TClass>,
): PropertyDecorator {
return (target: object, fieldKey: string) => {
const relationMetadataCollection =
TypedReflect.getMetadata(
'reflectRelationMetadataCollection',
target.constructor,
) ?? [];
const gate = TypedReflect.getMetadata('gate', target, fieldKey);
TypedReflect.defineMetadata(
'reflectRelationMetadataCollection',
[
...relationMetadataCollection,
{
target,
fieldKey,
...params,
onDelete: params.onDelete ?? RelationOnDeleteAction.SET_NULL,
gate,
} satisfies ReflectRelationMetadata,
],
target.constructor,
);
};
}

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
import { FeatureFlagEntity } from 'src/engine/modules/feature-flag/feature-flag.entity';
@Injectable()
export class FeatureFlagFactory {
constructor(
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {}
async create(context: WorkspaceSyncContext): Promise<FeatureFlagMap> {
const workspaceFeatureFlags = await this.featureFlagRepository.find({
where: { workspaceId: context.workspaceId },
});
const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce(
(result, currentFeatureFlag) => {
result[currentFeatureFlag.key] = currentFeatureFlag.value;
return result;
},
{} as FeatureFlagMap,
);
return workspaceFeatureFlagsMap;
}
}

View File

@ -0,0 +1,11 @@
import { FeatureFlagFactory } from './feature-flags.factory';
import { StandardFieldFactory } from './standard-field.factory';
import { StandardObjectFactory } from './standard-object.factory';
import { StandardRelationFactory } from './standard-relation.factory';
export const workspaceSyncMetadataFactories = [
FeatureFlagFactory,
StandardFieldFactory,
StandardObjectFactory,
StandardRelationFactory,
];

View File

@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
import {
PartialComputedFieldMetadata,
PartialFieldMetadata,
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { ReflectFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-field-metadata.interface';
import { ReflectObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-object-metadata.interface';
import { ReflectDynamicRelationFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-computed-relation-field-metadata.interface';
import { TypedReflect } from 'src/utils/typed-reflect';
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
@Injectable()
export class StandardFieldFactory {
create(
target: object,
context: WorkspaceSyncContext,
workspaceFeatureFlagsMap: FeatureFlagMap,
): (PartialFieldMetadata | PartialComputedFieldMetadata)[] {
const reflectObjectMetadata = TypedReflect.getMetadata(
'objectMetadata',
target,
);
const reflectFieldMetadataMap =
TypedReflect.getMetadata('fieldMetadataMap', target) ?? [];
const reflectDynamicRelationFieldMetadataMap = TypedReflect.getMetadata(
'dynamicRelationFieldMetadataMap',
target,
);
const partialFieldMetadataCollection: (
| PartialFieldMetadata
| PartialComputedFieldMetadata
)[] = Object.values(reflectFieldMetadataMap)
.map((reflectFieldMetadata) =>
this.createFieldMetadata(
reflectObjectMetadata,
reflectFieldMetadata,
context,
workspaceFeatureFlagsMap,
),
)
.filter((metadata): metadata is PartialFieldMetadata => !!metadata);
const partialComputedFieldMetadata = this.createComputedFieldMetadata(
reflectDynamicRelationFieldMetadataMap,
context,
workspaceFeatureFlagsMap,
);
if (partialComputedFieldMetadata) {
partialFieldMetadataCollection.push(partialComputedFieldMetadata);
}
return partialFieldMetadataCollection;
}
private createFieldMetadata(
reflectObjectMetadata: ReflectObjectMetadata | undefined,
reflectFieldMetadata: ReflectFieldMetadata[string],
context: WorkspaceSyncContext,
workspaceFeatureFlagsMap: FeatureFlagMap,
): PartialFieldMetadata | undefined {
if (
isGatedAndNotEnabled(reflectFieldMetadata.gate, workspaceFeatureFlagsMap)
) {
return undefined;
}
return {
...reflectFieldMetadata,
workspaceId: context.workspaceId,
isSystem:
reflectObjectMetadata?.isSystem || reflectFieldMetadata.isSystem,
};
}
private createComputedFieldMetadata(
reflectDynamicRelationFieldMetadata:
| ReflectDynamicRelationFieldMetadata
| undefined,
context: WorkspaceSyncContext,
workspaceFeatureFlagsMap: FeatureFlagMap,
): PartialComputedFieldMetadata | undefined {
if (
!reflectDynamicRelationFieldMetadata ||
isGatedAndNotEnabled(
reflectDynamicRelationFieldMetadata.gate,
workspaceFeatureFlagsMap,
)
) {
return undefined;
}
return {
...reflectDynamicRelationFieldMetadata,
workspaceId: context.workspaceId,
isSystem: reflectDynamicRelationFieldMetadata.isSystem,
};
}
}

View File

@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { PartialObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata';
import { TypedReflect } from 'src/utils/typed-reflect';
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
import { StandardFieldFactory } from './standard-field.factory';
@Injectable()
export class StandardObjectFactory {
constructor(private readonly standardFieldFactory: StandardFieldFactory) {}
create(
standardObjectMetadataDefinitions: (typeof BaseObjectMetadata)[],
context: WorkspaceSyncContext,
workspaceFeatureFlagsMap: FeatureFlagMap,
): PartialObjectMetadata[] {
return standardObjectMetadataDefinitions
.map((metadata) =>
this.createObjectMetadata(metadata, context, workspaceFeatureFlagsMap),
)
.filter((metadata): metadata is PartialObjectMetadata => !!metadata);
}
private createObjectMetadata(
metadata: typeof BaseObjectMetadata,
context: WorkspaceSyncContext,
workspaceFeatureFlagsMap: FeatureFlagMap,
): PartialObjectMetadata | undefined {
const objectMetadata = TypedReflect.getMetadata('objectMetadata', metadata);
if (!objectMetadata) {
throw new Error(
`Object metadata decorator not found, can\'t parse ${metadata.name}`,
);
}
if (isGatedAndNotEnabled(objectMetadata.gate, workspaceFeatureFlagsMap)) {
return undefined;
}
const fields = this.standardFieldFactory.create(
metadata,
context,
workspaceFeatureFlagsMap,
);
return {
...objectMetadata,
workspaceId: context.workspaceId,
dataSourceId: context.dataSourceId,
fields,
};
}
}

View File

@ -0,0 +1,169 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata';
import { TypedReflect } from 'src/utils/typed-reflect';
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
import { assert } from 'src/utils/assert';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
interface CustomRelationFactory {
object: ObjectMetadataEntity;
metadata: typeof BaseObjectMetadata;
}
@Injectable()
export class StandardRelationFactory {
create(
customObjectFactories: CustomRelationFactory[],
context: WorkspaceSyncContext,
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Partial<RelationMetadataEntity>[];
create(
standardObjectMetadataDefinitions: (typeof BaseObjectMetadata)[],
context: WorkspaceSyncContext,
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Partial<RelationMetadataEntity>[];
create(
standardObjectMetadataDefinitionsOrCustomObjectFactories:
| (typeof BaseObjectMetadata)[]
| {
object: ObjectMetadataEntity;
metadata: typeof BaseObjectMetadata;
}[],
context: WorkspaceSyncContext,
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Partial<RelationMetadataEntity>[] {
return standardObjectMetadataDefinitionsOrCustomObjectFactories.flatMap(
(
standardObjectMetadata:
| typeof BaseObjectMetadata
| CustomRelationFactory,
) =>
this.createRelationMetadata(
standardObjectMetadata,
context,
originalObjectMetadataMap,
workspaceFeatureFlagsMap,
),
);
}
private createRelationMetadata(
standardObjectMetadataOrCustomRelationFactory:
| typeof BaseObjectMetadata
| CustomRelationFactory,
context: WorkspaceSyncContext,
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Partial<RelationMetadataEntity>[] {
const standardObjectMetadata =
'metadata' in standardObjectMetadataOrCustomRelationFactory
? standardObjectMetadataOrCustomRelationFactory.metadata
: standardObjectMetadataOrCustomRelationFactory;
const objectMetadata = TypedReflect.getMetadata(
'metadata' in standardObjectMetadataOrCustomRelationFactory
? 'extendObjectMetadata'
: 'objectMetadata',
standardObjectMetadata,
);
const reflectRelationMetadataCollection = TypedReflect.getMetadata(
'reflectRelationMetadataCollection',
standardObjectMetadata,
);
if (!objectMetadata) {
throw new Error(
`Object metadata decorator not found, can\'t parse ${standardObjectMetadata.name}`,
);
}
if (
!reflectRelationMetadataCollection ||
isGatedAndNotEnabled(objectMetadata?.gate, workspaceFeatureFlagsMap)
) {
return [];
}
return reflectRelationMetadataCollection
.filter(
(reflectRelationMetadata) =>
!isGatedAndNotEnabled(
reflectRelationMetadata.gate,
workspaceFeatureFlagsMap,
),
)
.map((reflectRelationMetadata) => {
// Compute reflect relation metadata
const fromObjectNameSingular =
'object' in standardObjectMetadataOrCustomRelationFactory
? standardObjectMetadataOrCustomRelationFactory.object.nameSingular
: convertClassNameToObjectMetadataName(
reflectRelationMetadata.target.constructor.name,
);
const toObjectNameSingular = convertClassNameToObjectMetadataName(
reflectRelationMetadata.inverseSideTarget().name,
);
const fromFieldMetadataName = reflectRelationMetadata.fieldKey;
const toFieldMetadataName =
(reflectRelationMetadata.inverseSideFieldKey as string | undefined) ??
fromObjectNameSingular;
const fromObjectMetadata =
originalObjectMetadataMap[fromObjectNameSingular];
assert(
fromObjectMetadata,
`Object ${fromObjectNameSingular} not found in DB
for relation FROM defined in class ${fromObjectNameSingular}`,
);
const toObjectMetadata =
originalObjectMetadataMap[toObjectNameSingular];
assert(
toObjectMetadata,
`Object ${toObjectNameSingular} not found in DB
for relation TO defined in class ${fromObjectNameSingular}`,
);
const fromFieldMetadata = fromObjectMetadata?.fields.find(
(field) => field.name === fromFieldMetadataName,
);
assert(
fromFieldMetadata,
`Field ${fromFieldMetadataName} not found in object ${fromObjectNameSingular}
for relation FROM defined in class ${fromObjectNameSingular}`,
);
const toFieldMetadata = toObjectMetadata?.fields.find(
(field) => field.name === toFieldMetadataName,
);
assert(
toFieldMetadata,
`Field ${toFieldMetadataName} not found in object ${toObjectNameSingular}
for relation TO defined in class ${fromObjectNameSingular}`,
);
return {
relationType: reflectRelationMetadata.type,
fromObjectMetadataId: fromObjectMetadata?.id,
toObjectMetadataId: toObjectMetadata?.id,
fromFieldMetadataId: fromFieldMetadata?.id,
toFieldMetadataId: toFieldMetadata?.id,
workspaceId: context.workspaceId,
onDeleteAction: reflectRelationMetadata.onDelete,
};
});
}
}

View File

@ -0,0 +1,49 @@
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { ComputedPartialFieldMetadata } from './partial-field-metadata.interface';
import { ComputedPartialObjectMetadata } from './partial-object-metadata.interface';
export const enum ComparatorAction {
SKIP = 'SKIP',
CREATE = 'CREATE',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
}
export interface ComparatorSkipResult {
action: ComparatorAction.SKIP;
}
export interface ComparatorCreateResult<T> {
action: ComparatorAction.CREATE;
object: T;
}
export interface ComparatorUpdateResult<T> {
action: ComparatorAction.UPDATE;
object: T;
}
export interface ComparatorDeleteResult<T> {
action: ComparatorAction.DELETE;
object: T;
}
export type ObjectComparatorResult =
| ComparatorSkipResult
| ComparatorCreateResult<ComputedPartialObjectMetadata>
| ComparatorUpdateResult<Partial<ComputedPartialObjectMetadata>>;
export type FieldComparatorResult =
| ComparatorSkipResult
| ComparatorCreateResult<ComputedPartialFieldMetadata>
| ComparatorUpdateResult<
Partial<ComputedPartialFieldMetadata> & { id: string }
>
| ComparatorDeleteResult<FieldMetadataEntity>;
export type RelationComparatorResult =
| ComparatorCreateResult<Partial<RelationMetadataEntity>>
| ComparatorDeleteResult<RelationMetadataEntity>
| ComparatorUpdateResult<Partial<RelationMetadataEntity>>;

View File

@ -0,0 +1,3 @@
export interface GateDecoratorParams {
featureFlag: string;
}

View File

@ -0,0 +1,19 @@
import { PartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { PartialObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
export type MappedFieldMetadata = Record<string, PartialFieldMetadata>;
export interface MappedObjectMetadata
extends Omit<PartialObjectMetadata, 'fields'> {
fields: MappedFieldMetadata;
}
export type MappedFieldMetadataEntity = Record<string, FieldMetadataEntity>;
export interface MappedObjectMetadataEntity
extends Omit<ObjectMetadataEntity, 'fields'> {
fields: MappedFieldMetadataEntity;
}

View File

@ -0,0 +1,20 @@
import { ReflectDynamicRelationFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-computed-relation-field-metadata.interface';
import { ReflectFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-field-metadata.interface';
export type PartialFieldMetadata = Omit<
ReflectFieldMetadata[string],
'joinColumn'
> & {
workspaceId: string;
objectMetadataId?: string;
};
export type PartialComputedFieldMetadata =
ReflectDynamicRelationFieldMetadata & {
workspaceId: string;
objectMetadataId?: string;
};
export type ComputedPartialFieldMetadata = {
[K in keyof PartialFieldMetadata]: ExcludeFunctions<PartialFieldMetadata[K]>;
};

View File

@ -0,0 +1,21 @@
import {
ComputedPartialFieldMetadata,
PartialComputedFieldMetadata,
PartialFieldMetadata,
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { ReflectObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-object-metadata.interface';
export type PartialObjectMetadata = ReflectObjectMetadata & {
id?: string;
workspaceId: string;
dataSourceId: string;
fields: (PartialFieldMetadata | PartialComputedFieldMetadata)[];
};
export type ComputedPartialObjectMetadata = Omit<
PartialObjectMetadata,
'standardId' | 'fields'
> & {
standardId: string | null;
fields: ComputedPartialFieldMetadata[];
};

View File

@ -0,0 +1,10 @@
import { ReflectRelationMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-relation-metadata.interface';
export type PartialRelationMetadata = ReflectRelationMetadata & {
id: string;
workspaceId: string;
fromObjectMetadataId: string;
toObjectMetadataId: string;
fromFieldMetadataId: string;
toFieldMetadataId: string;
};

View File

@ -0,0 +1,24 @@
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
export type DynamicRelationFieldMetadataDecoratorParams = (
oppositeObjectMetadata: ObjectMetadataEntity,
) => {
standardId: string;
name: string;
label: string;
joinColumn: string;
description?: string;
icon?: string;
};
export interface ReflectDynamicRelationFieldMetadata {
type: FieldMetadataType.RELATION;
paramsFactory: DynamicRelationFieldMetadataDecoratorParams;
isNullable: boolean;
isSystem: boolean;
isCustom: boolean;
gate?: GateDecoratorParams;
}

View File

@ -0,0 +1,10 @@
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
export type BaseCustomObjectMetadataDecoratorParams =
| { allowObjectNameList?: string[] }
| { denyObjectNameList?: string[] };
export type ReflectBaseCustomObjectMetadata =
BaseCustomObjectMetadataDecoratorParams & {
gate?: GateDecoratorParams;
};

View File

@ -0,0 +1,37 @@
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
import { FieldMetadataOptions } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-options.interface';
import { FieldMetadataTargetColumnMap } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
export interface FieldMetadataDecoratorParams<
T extends FieldMetadataType | 'default',
> {
standardId: string;
type: T;
label: string | ((objectMetadata: ObjectMetadataEntity) => string);
description?: string | ((objectMetadata: ObjectMetadataEntity) => string);
icon?: string;
defaultValue?: FieldMetadataDefaultValue<T>;
joinColumn?: string;
options?: FieldMetadataOptions<T>;
}
export interface ReflectFieldMetadata {
[key: string]: Omit<
FieldMetadataDecoratorParams<'default'>,
'defaultValue' | 'type' | 'options'
> & {
name: string;
type: FieldMetadataType;
targetColumnMap: FieldMetadataTargetColumnMap<'default'>;
isNullable: boolean;
isSystem: boolean;
isCustom: boolean;
defaultValue: FieldMetadataDefaultValue<'default'> | null;
gate?: GateDecoratorParams;
options?: FieldMetadataOptions<'default'> | null;
};
}

View File

@ -0,0 +1,18 @@
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
export interface ObjectMetadataDecoratorParams {
standardId: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
}
export interface ReflectObjectMetadata extends ObjectMetadataDecoratorParams {
nameSingular: string;
targetTableName: string;
isSystem: boolean;
isCustom: boolean;
gate?: GateDecoratorParams;
}

View File

@ -0,0 +1,23 @@
import { ObjectType } from 'typeorm';
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
import {
RelationOnDeleteAction,
RelationMetadataType,
} from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
export interface RelationMetadataDecoratorParams<T> {
type: RelationMetadataType;
inverseSideTarget: () => ObjectType<T>;
inverseSideFieldKey?: keyof T;
onDelete?: RelationOnDeleteAction;
}
export interface ReflectRelationMetadata
extends RelationMetadataDecoratorParams<any> {
target: object;
fieldKey: string;
gate?: GateDecoratorParams;
onDelete: RelationOnDeleteAction;
}

View File

@ -0,0 +1,4 @@
export interface WorkspaceSyncContext {
workspaceId: string;
dataSourceId: string;
}

View File

@ -0,0 +1,270 @@
import { Injectable, Logger } from '@nestjs/common';
import { EntityManager, In } from 'typeorm';
import { v4 as uuidV4 } from 'uuid';
import omit from 'lodash.omit';
import { PartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { FieldMetadataComplexOption } from 'src/engine-metadata/field-metadata/dtos/options.input';
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
@Injectable()
export class WorkspaceMetadataUpdaterService {
private readonly logger = new Logger(WorkspaceMetadataUpdaterService.name);
async updateObjectMetadata(
manager: EntityManager,
storage: WorkspaceSyncStorage,
): Promise<{
createdObjectMetadataCollection: ObjectMetadataEntity[];
updatedObjectMetadataCollection: ObjectMetadataEntity[];
}> {
const objectMetadataRepository =
manager.getRepository(ObjectMetadataEntity);
/**
* Create object metadata
*/
const createdPartialObjectMetadataCollection =
await objectMetadataRepository.save(
storage.objectMetadataCreateCollection.map((objectMetadata) => ({
...objectMetadata,
isActive: true,
fields: objectMetadata.fields.map((field) =>
this.prepareFieldMetadataForCreation(field),
),
})) as DeepPartial<ObjectMetadataEntity>[],
);
const identifiers = createdPartialObjectMetadataCollection.map(
(object) => object.id,
);
const createdObjectMetadataCollection = await manager.find(
ObjectMetadataEntity,
{
where: { id: In(identifiers) },
relations: ['dataSource', 'fields'],
},
);
/**
* Update object metadata
*/
const updatedObjectMetadataCollection = await objectMetadataRepository.save(
storage.objectMetadataUpdateCollection.map((objectMetadata) =>
omit(objectMetadata, ['fields']),
),
);
/**
* Delete object metadata
*/
if (storage.objectMetadataDeleteCollection.length > 0) {
await objectMetadataRepository.delete(
storage.objectMetadataDeleteCollection.map((object) => object.id),
);
}
return {
createdObjectMetadataCollection,
updatedObjectMetadataCollection,
};
}
/**
* TODO: Refactor this
*/
private prepareFieldMetadataForCreation(field: PartialFieldMetadata) {
return {
...field,
...(field.type === FieldMetadataType.SELECT && field.options
? {
options: this.generateUUIDForNewSelectFieldOptions(
field.options as FieldMetadataComplexOption[],
),
}
: {}),
isActive: true,
};
}
private generateUUIDForNewSelectFieldOptions(
options: FieldMetadataComplexOption[],
): FieldMetadataComplexOption[] {
return options.map((option) => ({
...option,
id: uuidV4(),
}));
}
async updateFieldMetadata(
manager: EntityManager,
storage: WorkspaceSyncStorage,
): Promise<{
createdFieldMetadataCollection: FieldMetadataEntity[];
updatedFieldMetadataCollection: {
current: FieldMetadataEntity;
altered: FieldMetadataEntity;
}[];
}> {
const fieldMetadataRepository = manager.getRepository(FieldMetadataEntity);
/**
* Create field metadata
*/
const createdFieldMetadataCollection = await fieldMetadataRepository.save(
storage.fieldMetadataCreateCollection.map((field) =>
this.prepareFieldMetadataForCreation(field),
) as DeepPartial<FieldMetadataEntity>[],
);
/**
* Update field metadata
*/
const oldFieldMetadataCollection = await fieldMetadataRepository.findBy({
id: In(storage.fieldMetadataUpdateCollection.map((field) => field.id)),
});
// Pre-process old collection into a mapping for quick access
const oldFieldMetadataMap = new Map(
oldFieldMetadataCollection.map((field) => [field.id, field]),
);
// Combine old and new field metadata to get whole updated entities
const fieldMetadataUpdateCollection =
storage.fieldMetadataUpdateCollection.map((updateFieldMetadata) => {
const oldFieldMetadata = oldFieldMetadataMap.get(
updateFieldMetadata.id,
);
if (!oldFieldMetadata) {
throw new Error(`
Field ${updateFieldMetadata.id} not found in oldFieldMetadataCollection`);
}
// TypeORM 😢
// If we didn't provide the old value, it will be set to null fields that are not in the updateFieldMetadata
// and override the old value with null in the DB.
// Also save method doesn't return the whole entity if you give a partial one.
// https://github.com/typeorm/typeorm/issues/3490
// To avoid calling update in a for loop, we did this hack.
return {
...omit(oldFieldMetadata, ['objectMetadataId', 'workspaceId']),
...omit(updateFieldMetadata, ['objectMetadataId', 'workspaceId']),
options: updateFieldMetadata.options ?? oldFieldMetadata.options,
};
});
const updatedFieldMetadataCollection = await fieldMetadataRepository.save(
fieldMetadataUpdateCollection,
);
/**
* Delete field metadata
*/
// TODO: handle relation fields deletion. We need to delete the relation metadata first due to the DB constraint.
const fieldMetadataDeleteCollectionWithoutRelationType =
storage.fieldMetadataDeleteCollection.filter(
(field) => field.type !== FieldMetadataType.RELATION,
);
if (fieldMetadataDeleteCollectionWithoutRelationType.length > 0) {
await fieldMetadataRepository.delete(
fieldMetadataDeleteCollectionWithoutRelationType.map(
(field) => field.id,
),
);
}
return {
createdFieldMetadataCollection:
createdFieldMetadataCollection as FieldMetadataEntity[],
updatedFieldMetadataCollection: updatedFieldMetadataCollection.map(
(alteredFieldMetadata) => {
const oldFieldMetadata = oldFieldMetadataMap.get(
alteredFieldMetadata.id,
);
if (!oldFieldMetadata) {
throw new Error(`
Field ${alteredFieldMetadata.id} not found in oldFieldMetadataCollection
`);
}
return {
current: oldFieldMetadata as FieldMetadataEntity,
altered: {
...alteredFieldMetadata,
objectMetadataId: oldFieldMetadata.objectMetadataId,
workspaceId: oldFieldMetadata.workspaceId,
} as FieldMetadataEntity,
};
},
),
};
}
async updateRelationMetadata(
manager: EntityManager,
storage: WorkspaceSyncStorage,
): Promise<{
createdRelationMetadataCollection: RelationMetadataEntity[];
updatedRelationMetadataCollection: RelationMetadataEntity[];
}> {
const relationMetadataRepository = manager.getRepository(
RelationMetadataEntity,
);
const fieldMetadataRepository = manager.getRepository(FieldMetadataEntity);
/**
* Create relation metadata
*/
const createdRelationMetadataCollection =
await relationMetadataRepository.save(
storage.relationMetadataCreateCollection,
);
/**
* Update relation metadata
*/
const updatedRelationMetadataCollection =
await relationMetadataRepository.save(
storage.relationMetadataUpdateCollection,
);
/**
* Delete relation metadata
*/
if (storage.relationMetadataDeleteCollection.length > 0) {
await relationMetadataRepository.delete(
storage.relationMetadataDeleteCollection.map(
(relationMetadata) => relationMetadata.id,
),
);
}
/**
* Delete related field metadata
*/
const fieldMetadataDeleteCollectionOnlyRelation =
storage.fieldMetadataDeleteCollection.filter(
(field) => field.type === FieldMetadataType.RELATION,
);
if (fieldMetadataDeleteCollectionOnlyRelation.length > 0) {
await fieldMetadataRepository.delete(
fieldMetadataDeleteCollectionOnlyRelation.map((field) => field.id),
);
}
return {
createdRelationMetadataCollection,
updatedRelationMetadataCollection,
};
}
}

View File

@ -0,0 +1,140 @@
import { Injectable, Logger } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceFieldComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator';
import { WorkspaceMetadataUpdaterService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service';
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
import { WorkspaceMigrationFieldFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
import { StandardFieldFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory';
import { CustomObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/custom-objects/custom.object-metadata';
import { computeStandardObject } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/compute-standard-object.util';
@Injectable()
export class WorkspaceSyncFieldMetadataService {
private readonly logger = new Logger(WorkspaceSyncFieldMetadataService.name);
constructor(
private readonly standardFieldFactory: StandardFieldFactory,
private readonly workspaceFieldComparator: WorkspaceFieldComparator,
private readonly workspaceMetadataUpdaterService: WorkspaceMetadataUpdaterService,
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
) {}
async synchronize(
context: WorkspaceSyncContext,
manager: EntityManager,
storage: WorkspaceSyncStorage,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const objectMetadataRepository =
manager.getRepository(ObjectMetadataEntity);
// Retrieve object metadata collection from DB
const originalObjectMetadataCollection =
await objectMetadataRepository.find({
where: {
workspaceId: context.workspaceId,
// We're only interested in standard fields
fields: { isCustom: false },
},
relations: ['dataSource', 'fields'],
});
// Filter out custom objects
const customObjectMetadataCollection =
originalObjectMetadataCollection.filter(
(objectMetadata) => objectMetadata.isCustom,
);
// Create standard field metadata collection
const standardFieldMetadataCollection = this.standardFieldFactory.create(
CustomObjectMetadata,
context,
workspaceFeatureFlagsMap,
);
// Loop over all standard objects and compare them with the objects in DB
for (const customObjectMetadata of customObjectMetadataCollection) {
// Also, maybe it's better to refactor a bit and move generation part into a separate module ?
const standardObjectMetadata = computeStandardObject(
{
...customObjectMetadata,
fields: standardFieldMetadataCollection,
},
customObjectMetadata,
);
/**
* COMPARE FIELD METADATA
*/
const fieldComparatorResults = this.workspaceFieldComparator.compare(
customObjectMetadata,
standardObjectMetadata,
);
for (const fieldComparatorResult of fieldComparatorResults) {
switch (fieldComparatorResult.action) {
case ComparatorAction.CREATE: {
storage.addCreateFieldMetadata(fieldComparatorResult.object);
break;
}
case ComparatorAction.UPDATE: {
storage.addUpdateFieldMetadata(fieldComparatorResult.object);
break;
}
case ComparatorAction.DELETE: {
storage.addDeleteFieldMetadata(fieldComparatorResult.object);
break;
}
}
}
}
this.logger.log('Updating workspace metadata');
const metadataFieldUpdaterResult =
await this.workspaceMetadataUpdaterService.updateFieldMetadata(
manager,
storage,
);
this.logger.log('Generating migrations');
const createFieldWorkspaceMigrations =
await this.workspaceMigrationFieldFactory.create(
originalObjectMetadataCollection,
metadataFieldUpdaterResult.createdFieldMetadataCollection,
WorkspaceMigrationBuilderAction.CREATE,
);
const updateFieldWorkspaceMigrations =
await this.workspaceMigrationFieldFactory.create(
originalObjectMetadataCollection,
metadataFieldUpdaterResult.updatedFieldMetadataCollection,
WorkspaceMigrationBuilderAction.UPDATE,
);
const deleteFieldWorkspaceMigrations =
await this.workspaceMigrationFieldFactory.create(
originalObjectMetadataCollection,
storage.fieldMetadataDeleteCollection,
WorkspaceMigrationBuilderAction.DELETE,
);
this.logger.log('Saving migrations');
return [
...createFieldWorkspaceMigrations,
...updateFieldWorkspaceMigrations,
...deleteFieldWorkspaceMigrations,
];
}
}

View File

@ -0,0 +1,170 @@
import { Injectable, Logger } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { mapObjectMetadataByUniqueIdentifier } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/sync-metadata.util';
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { StandardObjectFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-object.factory';
import { WorkspaceObjectComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-object.comparator';
import { WorkspaceFieldComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator';
import { WorkspaceMetadataUpdaterService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service';
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
import { WorkspaceMigrationObjectFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-object.factory';
import { computeStandardObject } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/compute-standard-object.util';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
@Injectable()
export class WorkspaceSyncObjectMetadataService {
private readonly logger = new Logger(WorkspaceSyncObjectMetadataService.name);
constructor(
private readonly standardObjectFactory: StandardObjectFactory,
private readonly workspaceObjectComparator: WorkspaceObjectComparator,
private readonly workspaceFieldComparator: WorkspaceFieldComparator,
private readonly workspaceMetadataUpdaterService: WorkspaceMetadataUpdaterService,
private readonly workspaceMigrationObjectFactory: WorkspaceMigrationObjectFactory,
) {}
async synchronize(
context: WorkspaceSyncContext,
manager: EntityManager,
storage: WorkspaceSyncStorage,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const objectMetadataRepository =
manager.getRepository(ObjectMetadataEntity);
// Retrieve object metadata collection from DB
const originalObjectMetadataCollection =
await objectMetadataRepository.find({
where: {
workspaceId: context.workspaceId,
fields: { isCustom: false },
},
relations: ['dataSource', 'fields'],
});
const customObjectMetadataCollection =
originalObjectMetadataCollection.filter(
(objectMetadata) => objectMetadata.isCustom,
);
// Create standard object metadata collection
const standardObjectMetadataCollection = this.standardObjectFactory.create(
standardObjectMetadataDefinitions,
context,
workspaceFeatureFlagsMap,
);
// Create map of original and standard object metadata by standard ids
const originalObjectMetadataMap = mapObjectMetadataByUniqueIdentifier(
originalObjectMetadataCollection,
);
const standardObjectMetadataMap = mapObjectMetadataByUniqueIdentifier(
standardObjectMetadataCollection,
);
this.logger.log('Comparing standard objects and fields metadata');
// Store object that need to be deleted
for (const originalObjectMetadata of originalObjectMetadataCollection.filter(
(object) => !object.isCustom,
)) {
if (
originalObjectMetadata.standardId &&
!standardObjectMetadataMap[originalObjectMetadata.standardId]
) {
storage.addDeleteObjectMetadata(originalObjectMetadata);
}
}
// Loop over all standard objects and compare them with the objects in DB
for (const standardObjectId in standardObjectMetadataMap) {
const originalObjectMetadata =
originalObjectMetadataMap[standardObjectId];
const standardObjectMetadata = computeStandardObject(
standardObjectMetadataMap[standardObjectId],
originalObjectMetadata,
customObjectMetadataCollection,
);
/**
* COMPARE OBJECT METADATA
*/
const objectComparatorResult = this.workspaceObjectComparator.compare(
originalObjectMetadata,
standardObjectMetadata,
);
if (objectComparatorResult.action === ComparatorAction.CREATE) {
storage.addCreateObjectMetadata(standardObjectMetadata);
continue;
}
if (objectComparatorResult.action === ComparatorAction.UPDATE) {
storage.addUpdateObjectMetadata(objectComparatorResult.object);
}
/**
* COMPARE FIELD METADATA
*/
const fieldComparatorResults = this.workspaceFieldComparator.compare(
originalObjectMetadata,
standardObjectMetadata,
);
for (const fieldComparatorResult of fieldComparatorResults) {
switch (fieldComparatorResult.action) {
case ComparatorAction.CREATE: {
storage.addCreateFieldMetadata(fieldComparatorResult.object);
break;
}
case ComparatorAction.UPDATE: {
storage.addUpdateFieldMetadata(fieldComparatorResult.object);
break;
}
case ComparatorAction.DELETE: {
storage.addDeleteFieldMetadata(fieldComparatorResult.object);
break;
}
}
}
}
this.logger.log('Updating workspace metadata');
// Apply changes to DB
const metadataObjectUpdaterResult =
await this.workspaceMetadataUpdaterService.updateObjectMetadata(
manager,
storage,
);
this.logger.log('Generating migrations');
// Create migrations
const createObjectWorkspaceMigrations =
await this.workspaceMigrationObjectFactory.create(
metadataObjectUpdaterResult.createdObjectMetadataCollection,
WorkspaceMigrationBuilderAction.CREATE,
);
const deleteObjectWorkspaceMigrations =
await this.workspaceMigrationObjectFactory.create(
storage.objectMetadataDeleteCollection,
WorkspaceMigrationBuilderAction.DELETE,
);
this.logger.log('Saving migrations');
return [
...createObjectWorkspaceMigrations,
...deleteObjectWorkspaceMigrations,
];
}
}

View File

@ -0,0 +1,142 @@
import { Injectable, Logger } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { mapObjectMetadataByUniqueIdentifier } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/sync-metadata.util';
import { StandardRelationFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-relation.factory';
import { WorkspaceRelationComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-relation.comparator';
import { WorkspaceMetadataUpdaterService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service';
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
import { WorkspaceMigrationRelationFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-relation.factory';
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
import { CustomObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/custom-objects/custom.object-metadata';
@Injectable()
export class WorkspaceSyncRelationMetadataService {
private readonly logger = new Logger(
WorkspaceSyncRelationMetadataService.name,
);
constructor(
private readonly standardRelationFactory: StandardRelationFactory,
private readonly workspaceRelationComparator: WorkspaceRelationComparator,
private readonly workspaceMetadataUpdaterService: WorkspaceMetadataUpdaterService,
private readonly workspaceMigrationRelationFactory: WorkspaceMigrationRelationFactory,
) {}
async synchronize(
context: WorkspaceSyncContext,
manager: EntityManager,
storage: WorkspaceSyncStorage,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const objectMetadataRepository =
manager.getRepository(ObjectMetadataEntity);
// Retrieve object metadata collection from DB
const originalObjectMetadataCollection =
await objectMetadataRepository.find({
where: {
workspaceId: context.workspaceId,
fields: { isCustom: false },
},
relations: ['dataSource', 'fields'],
});
const customObjectMetadataCollection =
originalObjectMetadataCollection.filter(
(objectMetadata) => objectMetadata.isCustom,
);
// Create map of object metadata & field metadata by unique identifier
const originalObjectMetadataMap = mapObjectMetadataByUniqueIdentifier(
originalObjectMetadataCollection,
// Relation are based on the singular name
(objectMetadata) => objectMetadata.nameSingular,
);
const relationMetadataRepository = manager.getRepository(
RelationMetadataEntity,
);
// Retrieve relation metadata collection from DB
const originalRelationMetadataCollection =
await relationMetadataRepository.find({
where: {
workspaceId: context.workspaceId,
fromFieldMetadata: { isCustom: false },
},
});
// Create standard relation metadata collection
const standardRelationMetadataCollection =
this.standardRelationFactory.create(
standardObjectMetadataDefinitions,
context,
originalObjectMetadataMap,
workspaceFeatureFlagsMap,
);
const customRelationMetadataCollection =
this.standardRelationFactory.create(
customObjectMetadataCollection.map((objectMetadata) => ({
object: objectMetadata,
metadata: CustomObjectMetadata,
})),
context,
originalObjectMetadataMap,
workspaceFeatureFlagsMap,
);
const relationComparatorResults = this.workspaceRelationComparator.compare(
originalRelationMetadataCollection,
[
...standardRelationMetadataCollection,
...customRelationMetadataCollection,
],
);
for (const relationComparatorResult of relationComparatorResults) {
if (relationComparatorResult.action === ComparatorAction.CREATE) {
storage.addCreateRelationMetadata(relationComparatorResult.object);
} else if (relationComparatorResult.action === ComparatorAction.UPDATE) {
storage.addUpdateRelationMetadata(relationComparatorResult.object);
} else if (relationComparatorResult.action === ComparatorAction.DELETE) {
storage.addDeleteRelationMetadata(relationComparatorResult.object);
}
}
const metadataRelationUpdaterResult =
await this.workspaceMetadataUpdaterService.updateRelationMetadata(
manager,
storage,
);
// Create migrations
const createRelationWorkspaceMigrations =
await this.workspaceMigrationRelationFactory.create(
originalObjectMetadataCollection,
metadataRelationUpdaterResult.createdRelationMetadataCollection,
WorkspaceMigrationBuilderAction.CREATE,
);
const updateRelationWorkspaceMigrations =
await this.workspaceMigrationRelationFactory.create(
originalObjectMetadataCollection,
metadataRelationUpdaterResult.updatedRelationMetadataCollection,
WorkspaceMigrationBuilderAction.UPDATE,
);
return [
...createRelationWorkspaceMigrations,
...updateRelationWorkspaceMigrations,
];
}
}

View File

@ -0,0 +1,38 @@
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { baseObjectStandardFieldIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { FieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator';
import { IsSystem } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-system.decorator';
export abstract class BaseObjectMetadata {
@FieldMetadata({
standardId: baseObjectStandardFieldIds.id,
type: FieldMetadataType.UUID,
label: 'Id',
description: 'Id',
defaultValue: { type: 'uuid' },
icon: 'Icon123',
})
@IsSystem()
id: string;
@FieldMetadata({
standardId: baseObjectStandardFieldIds.createdAt,
type: FieldMetadataType.DATE_TIME,
label: 'Creation date',
description: 'Creation date',
icon: 'IconCalendar',
defaultValue: { type: 'now' },
})
createdAt: Date;
@FieldMetadata({
standardId: baseObjectStandardFieldIds.updatedAt,
type: FieldMetadataType.DATE_TIME,
label: 'Update date',
description: 'Update date',
icon: 'IconCalendar',
defaultValue: { type: 'now' },
})
@IsSystem()
updatedAt: Date;
}

View File

@ -0,0 +1,57 @@
import { ActivityTargetObjectMetadata } from 'src/business/modules/activity/activity-target.object-metadata';
import { ActivityObjectMetadata } from 'src/business/modules/activity/activity.object-metadata';
import { ApiKeyObjectMetadata } from 'src/business/modules/api-key/api-key.object-metadata';
import { AttachmentObjectMetadata } from 'src/business/modules/attachment/attachment.object-metadata';
import { BlocklistObjectMetadata } from 'src/business/modules/calendar/blocklist.object-metadata';
import { CalendarEventObjectMetadata } from 'src/business/modules/calendar/calendar-event.object-metadata';
import { CalendarChannelObjectMetadata } from 'src/business/modules/calendar/calendar-channel.object-metadata';
import { CalendarEventAttendeeObjectMetadata } from 'src/business/modules/calendar/calendar-event-attendee.object-metadata';
import { CommentObjectMetadata } from 'src/business/modules/comment/comment.object-metadata';
import { CompanyObjectMetadata } from 'src/business/modules/company/company.object-metadata';
import { ConnectedAccountObjectMetadata } from 'src/business/modules/connected-account/connected-account.object-metadata';
import { FavoriteObjectMetadata } from 'src/business/modules/favorite/favorite.object-metadata';
import { MessageChannelMessageAssociationObjectMetadata } from 'src/business/modules/message/message-channel-message-association.object-metadata';
import { MessageChannelObjectMetadata } from 'src/business/modules/message/message-channel.object-metadata';
import { MessageParticipantObjectMetadata } from 'src/business/modules/message/message-participant.object-metadata';
import { MessageThreadObjectMetadata } from 'src/business/modules/message/message-thread.object-metadata';
import { MessageObjectMetadata } from 'src/business/modules/message/message.object-metadata';
import { OpportunityObjectMetadata } from 'src/business/modules/opportunity/opportunity.object-metadata';
import { PersonObjectMetadata } from 'src/business/modules/person/person.object-metadata';
import { PipelineStepObjectMetadata } from 'src/business/modules/pipeline-step/pipeline-step.object-metadata';
import { ViewFieldObjectMetadata } from 'src/business/modules/view/view-field.object-metadata';
import { ViewFilterObjectMetadata } from 'src/business/modules/view/view-filter.object-metadata';
import { ViewSortObjectMetadata } from 'src/business/modules/view/view-sort.object-metadata';
import { ViewObjectMetadata } from 'src/business/modules/view/view.object-metadata';
import { WebhookObjectMetadata } from 'src/business/modules/webhook/webhook.object-metadata';
import { WorkspaceMemberObjectMetadata } from 'src/business/modules/workspace/workspace-member.object-metadata';
import { CalendarChannelEventAssociationObjectMetadata } from 'src/business/modules/calendar/calendar-channel-event-association.object-metadata';
export const standardObjectMetadataDefinitions = [
ActivityTargetObjectMetadata,
ActivityObjectMetadata,
ApiKeyObjectMetadata,
AttachmentObjectMetadata,
BlocklistObjectMetadata,
CommentObjectMetadata,
CompanyObjectMetadata,
ConnectedAccountObjectMetadata,
FavoriteObjectMetadata,
OpportunityObjectMetadata,
PersonObjectMetadata,
PipelineStepObjectMetadata,
ViewFieldObjectMetadata,
ViewFilterObjectMetadata,
ViewSortObjectMetadata,
ViewObjectMetadata,
WebhookObjectMetadata,
WorkspaceMemberObjectMetadata,
MessageThreadObjectMetadata,
MessageObjectMetadata,
MessageChannelObjectMetadata,
MessageParticipantObjectMetadata,
MessageChannelMessageAssociationObjectMetadata,
CalendarEventObjectMetadata,
CalendarChannelObjectMetadata,
CalendarChannelEventAssociationObjectMetadata,
CalendarEventAttendeeObjectMetadata,
];

View File

@ -0,0 +1,108 @@
import { ComputedPartialObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { ComputedPartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { PartialRelationMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-relation-metadata.interface';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
export class WorkspaceSyncStorage {
// Object metadata
private readonly _objectMetadataCreateCollection: ComputedPartialObjectMetadata[] =
[];
private readonly _objectMetadataUpdateCollection: Partial<ComputedPartialObjectMetadata>[] =
[];
private readonly _objectMetadataDeleteCollection: ObjectMetadataEntity[] = [];
// Field metadata
private readonly _fieldMetadataCreateCollection: ComputedPartialFieldMetadata[] =
[];
private readonly _fieldMetadataUpdateCollection: (Partial<ComputedPartialFieldMetadata> & {
id: string;
})[] = [];
private readonly _fieldMetadataDeleteCollection: FieldMetadataEntity[] = [];
// Relation metadata
private readonly _relationMetadataCreateCollection: Partial<RelationMetadataEntity>[] =
[];
private readonly _relationMetadataDeleteCollection: RelationMetadataEntity[] =
[];
private readonly _relationMetadataUpdateCollection: Partial<PartialRelationMetadata>[] =
[];
constructor() {}
get objectMetadataCreateCollection() {
return this._objectMetadataCreateCollection;
}
get objectMetadataUpdateCollection() {
return this._objectMetadataUpdateCollection;
}
get objectMetadataDeleteCollection() {
return this._objectMetadataDeleteCollection;
}
get fieldMetadataCreateCollection() {
return this._fieldMetadataCreateCollection;
}
get fieldMetadataUpdateCollection() {
return this._fieldMetadataUpdateCollection;
}
get fieldMetadataDeleteCollection() {
return this._fieldMetadataDeleteCollection;
}
get relationMetadataCreateCollection() {
return this._relationMetadataCreateCollection;
}
get relationMetadataUpdateCollection() {
return this._relationMetadataUpdateCollection;
}
get relationMetadataDeleteCollection() {
return this._relationMetadataDeleteCollection;
}
addCreateObjectMetadata(object: ComputedPartialObjectMetadata) {
this._objectMetadataCreateCollection.push(object);
}
addUpdateObjectMetadata(object: Partial<ComputedPartialObjectMetadata>) {
this._objectMetadataUpdateCollection.push(object);
}
addDeleteObjectMetadata(object: ObjectMetadataEntity) {
this._objectMetadataDeleteCollection.push(object);
}
addCreateFieldMetadata(field: ComputedPartialFieldMetadata) {
this._fieldMetadataCreateCollection.push(field);
}
addUpdateFieldMetadata(
field: Partial<ComputedPartialFieldMetadata> & { id: string },
) {
this._fieldMetadataUpdateCollection.push(field);
}
addDeleteFieldMetadata(field: FieldMetadataEntity) {
this._fieldMetadataDeleteCollection.push(field);
}
addCreateRelationMetadata(relation: Partial<RelationMetadataEntity>) {
this._relationMetadataCreateCollection.push(relation);
}
addUpdateRelationMetadata(relation: Partial<PartialRelationMetadata>) {
this._relationMetadataUpdateCollection.push(relation);
}
addDeleteRelationMetadata(relation: RelationMetadataEntity) {
this._relationMetadataDeleteCollection.push(relation);
}
}

View File

@ -0,0 +1,9 @@
import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata';
export type ObjectRecord<T extends BaseObjectMetadata> = {
[K in keyof T as T[K] extends BaseObjectMetadata
? `${Extract<K, string>}Id`
: K]: T[K] extends BaseObjectMetadata ? string : T[K];
} & {
[K in keyof T]: T[K] extends BaseObjectMetadata ? ObjectRecord<T[K]> : T[K];
};

View File

@ -0,0 +1,76 @@
import {
ComputedPartialObjectMetadata,
PartialObjectMetadata,
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { ComputedPartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { createDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util';
export const computeStandardObject = (
standardObjectMetadata: Omit<PartialObjectMetadata, 'standardId'> & {
standardId: string | null;
},
originalObjectMetadata: ObjectMetadataEntity,
customObjectMetadataCollection: ObjectMetadataEntity[] = [],
): ComputedPartialObjectMetadata => {
const fields: ComputedPartialFieldMetadata[] = [];
for (const partialFieldMetadata of standardObjectMetadata.fields) {
if ('paramsFactory' in partialFieldMetadata) {
// Compute standard fields of custom object
for (const customObjectMetadata of customObjectMetadataCollection) {
const { paramsFactory, ...rest } = partialFieldMetadata;
const { joinColumn, ...data } = paramsFactory(customObjectMetadata);
// Relation
fields.push({
...data,
...rest,
defaultValue: null,
targetColumnMap: {},
});
// Foreign key
fields.push({
...rest,
standardId: createDeterministicUuid(data.standardId),
name: joinColumn,
type: FieldMetadataType.UUID,
label: `${data.label} ID (foreign key)`,
description: `${data.description} id foreign key`,
defaultValue: null,
icon: undefined,
targetColumnMap: generateTargetColumnMap(
FieldMetadataType.UUID,
rest.isCustom,
joinColumn,
),
isSystem: true,
});
}
} else {
const labelText =
typeof partialFieldMetadata.label === 'function'
? partialFieldMetadata.label(originalObjectMetadata)
: partialFieldMetadata.label;
const descriptionText =
typeof partialFieldMetadata.description === 'function'
? partialFieldMetadata.description(originalObjectMetadata)
: partialFieldMetadata.description;
fields.push({
...partialFieldMetadata,
label: labelText,
description: descriptionText,
});
}
}
return {
...standardObjectMetadata,
fields,
};
};

View File

@ -0,0 +1,12 @@
import { camelCase } from 'src/utils/camel-case';
export const convertClassNameToObjectMetadataName = (name: string): string => {
const classSuffix = 'ObjectMetadata';
let objectName = camelCase(name);
if (objectName.endsWith(classSuffix)) {
objectName = objectName.slice(0, -classSuffix.length);
}
return objectName;
};

View File

@ -0,0 +1,10 @@
import { createHash } from 'crypto';
export const createDeterministicUuid = (inputUuid: string): string => {
const hash = createHash('sha256').update(inputUuid).digest('hex');
return `20202020-${hash.substring(0, 4)}-4${hash.substring(
4,
7,
)}-8${hash.substring(7, 10)}-${hash.substring(10, 22)}`;
};

View File

@ -0,0 +1,11 @@
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
export const isGatedAndNotEnabled = (
gate: GateDecoratorParams | undefined,
workspaceFeatureFlagsMap: Record<string, boolean>,
): boolean => {
const featureFlagValue =
gate?.featureFlag && workspaceFeatureFlagsMap[gate.featureFlag];
return gate?.featureFlag !== undefined && !featureFlagValue;
};

View File

@ -0,0 +1,60 @@
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { mapObjectMetadataByUniqueIdentifier } from './sync-metadata.util';
describe('mapObjectMetadataByUniqueIdentifier', () => {
it('should convert an array of ObjectMetadataEntity objects into a map', () => {
const arr: DeepPartial<ObjectMetadataEntity>[] = [
{
standardId: 'user',
nameSingular: 'user',
fields: [
{ name: 'id', type: FieldMetadataType.UUID },
{ name: 'name', type: FieldMetadataType.TEXT },
],
},
{
standardId: 'product',
nameSingular: 'product',
fields: [
{ name: 'id', type: FieldMetadataType.UUID },
{ name: 'name', type: FieldMetadataType.TEXT },
{ name: 'price', type: FieldMetadataType.UUID },
],
},
];
const mappedObject = mapObjectMetadataByUniqueIdentifier(
arr as ObjectMetadataEntity[],
);
expect(mappedObject).toEqual({
user: {
standardId: 'user',
nameSingular: 'user',
fields: [
{ name: 'id', type: FieldMetadataType.UUID },
{ name: 'name', type: FieldMetadataType.TEXT },
],
},
product: {
standardId: 'product',
nameSingular: 'product',
fields: [
{ name: 'id', type: FieldMetadataType.UUID },
{ name: 'name', type: FieldMetadataType.TEXT },
{ name: 'price', type: FieldMetadataType.UUID },
],
},
});
});
it('should return an empty map if the input array is empty', () => {
const arr: ObjectMetadataEntity[] = [];
const mappedObject = mapObjectMetadataByUniqueIdentifier(arr);
expect(mappedObject).toEqual({});
});
});

View File

@ -0,0 +1,31 @@
/**
* This utility function converts an array of ObjectMetadataEntity objects into a map,
* where the keys are the nameSingular properties of the objects.
* Each object in the map contains the original object metadata and its fields as a nested map.
*
* @param arr - The array of ObjectMetadataEntity objects to convert.
* @returns A map of object metadata, with nameSingular as the key and the object as the value.
*/
export const mapObjectMetadataByUniqueIdentifier = <
T extends { standardId: string | null },
>(
arr: T[],
keyFactory: (obj: T) => string | null = (obj) => obj.standardId,
): Record<string, T> => {
return arr.reduce(
(acc, curr) => {
const key = keyFactory(curr);
if (!key) {
return acc;
}
acc[key] = {
...curr,
};
return acc;
},
{} as Record<string, T>,
);
};

View File

@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagEntity } from 'src/engine/modules/feature-flag/feature-flag.entity';
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service';
import { workspaceSyncMetadataFactories } from 'src/engine/workspace-manager/workspace-sync-metadata/factories';
import { workspaceSyncMetadataComparators } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators';
import { WorkspaceMetadataUpdaterService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service';
import { WorkspaceSyncObjectMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-object-metadata.service';
import { WorkspaceSyncRelationMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-relation-metadata.service';
import { WorkspaceSyncFieldMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata.service';
import { WorkspaceMigrationBuilderModule } from 'src/engine/workspace-manager/workspace-migration-builder/workspace-migration-builder.module';
@Module({
imports: [
WorkspaceMigrationBuilderModule,
WorkspaceMigrationRunnerModule,
TypeOrmModule.forFeature(
[
FieldMetadataEntity,
ObjectMetadataEntity,
RelationMetadataEntity,
WorkspaceMigrationEntity,
],
'metadata',
),
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
],
providers: [
...workspaceSyncMetadataFactories,
...workspaceSyncMetadataComparators,
WorkspaceMetadataUpdaterService,
WorkspaceSyncObjectMetadataService,
WorkspaceSyncRelationMetadataService,
WorkspaceSyncFieldMetadataService,
WorkspaceSyncMetadataService,
],
exports: [...workspaceSyncMetadataFactories, WorkspaceSyncMetadataService],
})
export class WorkspaceSyncMetadataModule {}

View File

@ -0,0 +1,137 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { FeatureFlagFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/feature-flags.factory';
import { WorkspaceSyncObjectMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-object-metadata.service';
import { WorkspaceSyncRelationMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-relation-metadata.service';
import { WorkspaceSyncFieldMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata.service';
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
interface SynchronizeOptions {
applyChanges?: boolean;
}
@Injectable()
export class WorkspaceSyncMetadataService {
private readonly logger = new Logger(WorkspaceSyncMetadataService.name);
constructor(
@InjectDataSource('metadata')
private readonly metadataDataSource: DataSource,
private readonly featureFlagFactory: FeatureFlagFactory,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceSyncObjectMetadataService: WorkspaceSyncObjectMetadataService,
private readonly workspaceSyncRelationMetadataService: WorkspaceSyncRelationMetadataService,
private readonly workspaceSyncFieldMetadataService: WorkspaceSyncFieldMetadataService,
) {}
/**
*
* Sync all standard objects and fields metadata for a given workspace and data source
* This will update the metadata if it has changed and generate migrations based on the diff.
*
* @param dataSourceId
* @param workspaceId
*/
public async synchronize(
context: WorkspaceSyncContext,
options: SynchronizeOptions = { applyChanges: true },
): Promise<{
workspaceMigrations: WorkspaceMigrationEntity[];
storage: WorkspaceSyncStorage;
}> {
let workspaceMigrations: WorkspaceMigrationEntity[] = [];
const storage = new WorkspaceSyncStorage();
const queryRunner = this.metadataDataSource.createQueryRunner();
this.logger.log('Syncing standard objects and fields metadata');
await queryRunner.connect();
await queryRunner.startTransaction();
const manager = queryRunner.manager;
try {
const workspaceMigrationRepository = manager.getRepository(
WorkspaceMigrationEntity,
);
// Retrieve feature flags
const workspaceFeatureFlagsMap =
await this.featureFlagFactory.create(context);
this.logger.log('Syncing standard objects and fields metadata');
// 1 - Sync standard objects
const workspaceObjectMigrations =
await this.workspaceSyncObjectMetadataService.synchronize(
context,
manager,
storage,
workspaceFeatureFlagsMap,
);
// 2 - Sync standard fields on custom objects
const workspaceFieldMigrations =
await this.workspaceSyncFieldMetadataService.synchronize(
context,
manager,
storage,
workspaceFeatureFlagsMap,
);
// 3 - Sync standard relations on standard and custom objects
const workspaceRelationMigrations =
await this.workspaceSyncRelationMetadataService.synchronize(
context,
manager,
storage,
workspaceFeatureFlagsMap,
);
// Save workspace migrations into the database
workspaceMigrations = await workspaceMigrationRepository.save([
...workspaceObjectMigrations,
...workspaceFieldMigrations,
...workspaceRelationMigrations,
]);
// If we're running a dry run, rollback the transaction and do not execute migrations
if (!options.applyChanges) {
this.logger.log('Running in dry run mode, rolling back transaction');
await queryRunner.rollbackTransaction();
await queryRunner.release();
return {
workspaceMigrations,
storage,
};
}
await queryRunner.commitTransaction();
// Execute migrations
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
context.workspaceId,
);
} catch (error) {
console.error('Sync of standard objects failed with:', error);
await queryRunner.rollbackTransaction();
} finally {
await queryRunner.release();
}
return {
workspaceMigrations,
storage,
};
}
}