feat (search): add dynamic search field metadata foundation for database-driven search configuration (#14469)

## Summary

Implements the foundation for dynamic search field configuration from
[issue #1428](https://github.com/twentyhq/core-team-issues/issues/1428).

## Changes

- Add `SearchFieldMetadataEntity` junction table for storing searchable
field configurations
- Add `SearchFieldMetadataService` with core CRUD operations  
- Add `SearchFieldMetadataModule` following existing patterns  
- Add `IS_DYNAMIC_SEARCH_FIELDS_ENABLED` feature flag (defaults to
`false`)
- Database migration with proper indexes and foreign keys  

## Architecture

Uses a junction table where **record existence = field is searchable**:

**Table: `searchFieldMetadata(objectMetadataId, fieldMetadataId,
workspaceId)`**

- Unique constraint on `(objectMetadataId, fieldMetadataId)`  
- `ON DELETE CASCADE` on foreign keys  

## Testing

- [x] Migration runs successfully  
- [x] Table created with correct schema  
- [x] Feature flag seeded properly  
- [x] Database reset works correctly  

## Next Steps

Future PRs will handle:

- Data migration from existing hardcoded search field configs  
- Integration with search services  

 No breaking changes — fully backward compatible.
This commit is contained in:
Abdullah.
2025-09-19 21:35:51 +05:00
committed by GitHub
parent a89cc3b234
commit ff3467c18e
10 changed files with 138 additions and 0 deletions
@@ -1062,6 +1062,7 @@ export enum FeatureFlagKey {
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
@@ -1026,6 +1026,7 @@ export enum FeatureFlagKey {
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
@@ -0,0 +1,41 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddSearchFieldMetadataEntity1757806282417
implements MigrationInterface
{
name = 'AddSearchFieldMetadataEntity1757806282417';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."searchFieldMetadata" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "objectMetadataId" uuid NOT NULL, "fieldMetadataId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "workspaceId" uuid NOT NULL, CONSTRAINT "IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE" UNIQUE ("objectMetadataId", "fieldMetadataId"), CONSTRAINT "PK_085190eb7531f4aeb8ccab3f42c" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID" ON "core"."searchFieldMetadata" ("objectMetadataId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID" ON "core"."searchFieldMetadata" ("workspaceId") `,
);
await queryRunner.query(
`ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_1b78544eb06f82059a2a01013a3" FOREIGN KEY ("objectMetadataId") REFERENCES "core"."objectMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_6d5c6922bfd1578b1eff2abb9d6" FOREIGN KEY ("fieldMetadataId") REFERENCES "core"."fieldMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_6d5c6922bfd1578b1eff2abb9d6"`,
);
await queryRunner.query(
`ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_1b78544eb06f82059a2a01013a3"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID"`,
);
await queryRunner.query(`DROP TABLE "core"."searchFieldMetadata"`);
}
}
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddWorkspaceForeignKeyToSearchFieldMetadata1757809958470
implements MigrationInterface
{
name = 'AddWorkspaceForeignKeyToSearchFieldMetadata1757809958470';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."searchFieldMetadata" ADD CONSTRAINT "FK_5f10e00da471e19f52513f47d8b" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."searchFieldMetadata" DROP CONSTRAINT "FK_5f10e00da471e19f52513f47d8b"`,
);
}
}
@@ -18,4 +18,5 @@ export enum FeatureFlagKey {
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_DYNAMIC_SEARCH_FIELDS_ENABLED = 'IS_DYNAMIC_SEARCH_FIELDS_ENABLED',
}
@@ -7,6 +7,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RemoteServerModule } from 'src/engine/metadata-modules/remote-server/remote-server.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@@ -17,6 +18,7 @@ import { RouteModule } from 'src/engine/metadata-modules/route/route.module';
DataSourceModule,
FieldMetadataModule,
ObjectMetadataModule,
SearchFieldMetadataModule,
ServerlessFunctionModule,
AgentModule,
WorkspaceMetadataVersionModule,
@@ -31,6 +33,7 @@ import { RouteModule } from 'src/engine/metadata-modules/route/route.module';
DataSourceModule,
FieldMetadataModule,
ObjectMetadataModule,
SearchFieldMetadataModule,
ServerlessFunctionModule,
AgentModule,
RemoteServerModule,
@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Entity('searchFieldMetadata')
@Unique('IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE', [
'objectMetadataId',
'fieldMetadataId',
])
@Index('IDX_SEARCH_FIELD_METADATA_WORKSPACE_ID', ['workspaceId'])
@Index('IDX_SEARCH_FIELD_METADATA_OBJECT_METADATA_ID', ['objectMetadataId'])
export class SearchFieldMetadataEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
objectMetadataId: string;
@ManyToOne(() => ObjectMetadataEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'objectMetadataId' })
objectMetadata: Relation<ObjectMetadataEntity>;
@Column({ nullable: false, type: 'uuid' })
fieldMetadataId: string;
@ManyToOne(() => FieldMetadataEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'fieldMetadataId' })
fieldMetadata: Relation<FieldMetadataEntity>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@ManyToOne(() => Workspace, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SearchFieldMetadataEntity } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.entity';
@Module({
imports: [TypeOrmModule.forFeature([SearchFieldMetadataEntity])],
providers: [],
exports: [],
})
export class SearchFieldMetadataModule {}
@@ -141,6 +141,7 @@ describe('WorkspaceEntityManager', () => {
IS_CALENDAR_VIEW_ENABLED: false,
IS_GROUP_BY_ENABLED: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_DYNAMIC_SEARCH_FIELDS_ENABLED: false,
},
eventEmitterService: {
emitMutationEvent: jest.fn(),
@@ -85,6 +85,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IS_DYNAMIC_SEARCH_FIELDS_ENABLED,
workspaceId: workspaceId,
value: false,
},
])
.execute();
};