feat(data-model): custom-indexes management UI and mutations (#20846)

## Summary

Brings indexes management into the per-object Settings tab as a section
under Search (no feature flag, advanced mode only). Admins can create /
delete non-unique indexes with the UI; apps can declare indexes in code
with `defineIndex`. Composite-typed fields are now indexable by picking
a specific sub-column (e.g. `Address > City`).

A few related polish items also land here (invite-user dropdown lands on
the Invite tab; standard warning callout above the new-index form).

## What ships

### UI — custom indexes on per-object Settings
- New section directly under Search, wrapped in
`AdvancedSettingsWrapper`.
- Filter dropdown on the search bar toggles system-index visibility
(shown by default since advanced mode).
- **+ Add Index** button (disabled with tooltip once the per-object cap
is reached) navigates to a dedicated `SettingsObjectNewIndex` page
(matches the field-creation pattern, not a modal):
- Field picker mirrors the webhook event-form layout (rows of dropdowns,
implicit trailing empty row).
- Composite fields surface their sub-properties (`Address > City`,
`Currency > Amount`, …).
  - BTREE / GIN type selector.
- Standard warning Callout: "Use indexes sparingly — each one speeds
reads but slows writes."
- Trash icon on `isCustom: true` rows → confirmation modal →
`deleteOneIndex`.

### Server — `createOneIndex` / `deleteOneIndex` mutations
- Gated by `SettingsPermissionGuard(DATA_MODEL)`.
- `IndexMetadataService` wraps the existing migration runner via
`WorkspaceMigrationValidateBuildAndRunService` so the metadata row and
the SQL index land atomically.
- Validation: rejects empty fields, duplicate `(fieldMetadataId,
subFieldName)` pairs, fields not on the object, requires `subFieldName`
for composite parents, forbids `subFieldName` on scalar/relation,
enforces `MAX_CUSTOM_INDEXES_PER_OBJECT = 10`.
- Delete refuses on `isCustom: false` rows so system indexes can't be
removed via this API.
- Dedicated GraphQL exception handler maps each typed error to the right
transport error class.

### Composite sub-field indexing
- Adds `subFieldName: string | null` column to
`IndexFieldMetadataEntity` (fast instance command).
- The flat-entity flow (`UniversalFlatIndexFieldMetadata`,
`FlatIndexFieldMetadata`, `from-universal-flat-index-to-flat-index`,
runner column resolution) all carry `subFieldName` through.
- For composite parents, the runner uses
`computeCompositeColumnName({...}, property)` for the picked sub-column;
for non-composite parents, behavior is unchanged.
- The `'::'` separator encodes `(fieldMetadataId, subFieldName)` for
dedup on the wire; the frontend uses the same separator inside the
Select component's string value.

### Apps can declare indexes in code (`defineIndex`)
- New `IndexManifest` + `IndexFieldManifest` types in
`twenty-shared/application` wired into the `Manifest` type.
- `defineIndex` SDK helper + `IndexConfig`. CLI manifest builder +
extractor recognize `defineIndex` / `ManifestEntityKey.Indexes`.
- Server: `from-index-manifest-to-universal-flat-index` converter
resolves field IDs, validates composite/scalar `subFieldName` rules, and
delegates to `generateFlatIndexMetadataWithNameOrThrow` for the
deterministic name.
- Orchestrator wires the loop after the field-resolution pass;
per-object cap enforced inline against the manifest.
- Cascade on uninstall is automatic — when an app disappears its indexes
drop with it (universal-flat-entity diff handles it).
- Rich-app fixture ships a real `defineIndex` on `PostCard.status`,
exercising the full manifest → install path in CI.

### Closed for now (open later if needed)
- Apps cannot declare `isUnique` indexes — unique constraints stay with
the field-creation flow.
- Apps cannot use a partial-`indexWhereClause` — the UI surface keeps
the framework's hardcoded allowlist.
- UI cannot create unique or partial indexes either; same reasons.

### Cleanups along the way
- Reused the existing `getCompositeSubFieldLabel` +
`COMPOSITE_FIELD_SUB_FIELD_LABELS` (deleted the duplicates I'd created
early in the PR).
- Moved `MAX_CUSTOM_INDEXES_PER_OBJECT` to `twenty-shared/constants`
(single source for FE + BE).
- Replaced inline `isDefined(x) && x !== ''` with `isNonEmptyString`
(from `@sniptt/guards`).
- Hoisted the per-object fields Map + inlined the cap counter into the
indexes orchestrator loop (drops the install scan from O(indexes ×
totalFields) to O(totalFields + indexes)).
- Per design-feedback: page-based create flow (not a modal), filter
dropdown on the SearchInput (not a separate toggle), webhook-style
picker, field icons.

### Unrelated polish that lands here
- "Invite user" link in the multi-workspace dropdown now lands on the
Invite tab directly (`#invite`) instead of the first tab of the members
page.

## Test plan
- [ ] `npx nx typecheck twenty-server / twenty-front / twenty-sdk /
twenty-shared` — passes
- [ ] `npx nx lint:diff-with-main twenty-server / twenty-front` — clean
- [ ] `npx jest index-metadata.service.spec` — green
- [ ] `npx jest from-index-manifest-to-universal-flat-index` — green
(new converter spec, 8 cases)
- [ ] `npx vitest run
src/sdk/define/indexes/__tests__/define-index.spec.ts` (twenty-sdk) —
green (6 cases)
- [ ] `npx vitest run --config vitest.integration.config.ts -t
"rich-app"` — green (rich-app app-dev integration exercises the new
manifest path with the PostCard.status index)
- [ ] Advanced mode → Settings → any object → Settings tab → Indexes
section is visible under Search
- [ ] Create a single-field BTREE index, confirm SQL index exists
(verify via `pg_indexes`)
- [ ] Create a composite-field index (`Address > City`) and confirm the
column is `addressAddressCity`
- [ ] Create an index spanning two columns; column order matches the
picker order
- [ ] Attempt to create an 11th custom index → button is disabled with
tooltip
- [ ] Delete a custom index → confirmation modal → row disappears, PG
index dropped
- [ ] System indexes have no trash icon and are hidden by default
This commit is contained in:
Félix Malfait
2026-05-25 17:47:09 +02:00
committed by GitHub
parent 69d89f8cfc
commit d602f35cbd
98 changed files with 3742 additions and 387 deletions
@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`generateFlatIndexMetadataWithNameOrThrow pins names for representative standard-index shapes 1`] = `
{
"manyToOneJoin": "IDX_f719a95179070eac397ba18dc70",
"partialUnique": "IDX_UNIQUE_10f41e6e837c3c40fa07ca9d76f",
"scalarNonUnique": "IDX_f6529884d22eaf60dd0bfc9f831",
"scalarUnique": "IDX_UNIQUE_2a32339058d0b6910b0834ddf81",
"searchVectorGin": "IDX_fb1f4905546cfc6d70a971c76f7",
}
`;
@@ -0,0 +1,148 @@
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
// Pin the deterministic index names produced by the shared engine for
// canonical input shapes. Any future engine refactor that perturbs a hash
// trips here locally instead of after a workspace migration starts failing
// on a customer install.
describe('generateFlatIndexMetadataWithNameOrThrow', () => {
const now = '2026-05-25T00:00:00.000Z';
const companyObject = {
universalIdentifier: 'obj-company',
nameSingular: 'company',
isCustom: false,
} as UniversalFlatObjectMetadata;
const scalarUniqueField = {
universalIdentifier: 'field-domain',
name: 'domainName',
type: FieldMetadataType.TEXT,
isUnique: true,
} as UniversalFlatFieldMetadata;
const scalarNonUniqueField = {
universalIdentifier: 'field-employees',
name: 'employees',
type: FieldMetadataType.NUMBER,
isUnique: false,
} as UniversalFlatFieldMetadata;
const tsVectorField = {
universalIdentifier: 'field-search',
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
isUnique: false,
} as UniversalFlatFieldMetadata;
const manyToOneRelationField = {
universalIdentifier: 'field-account-owner',
name: 'accountOwner',
type: FieldMetadataType.RELATION,
isUnique: false,
universalSettings: { relationType: RelationType.MANY_TO_ONE },
} as unknown as UniversalFlatFieldMetadata;
const buildIndex = (overrides: {
universalIdentifier: string;
isUnique: boolean;
indexType: IndexType;
fieldIds: string[];
indexWhereClause?: string | null;
}) => ({
createdAt: now,
updatedAt: now,
universalIdentifier: overrides.universalIdentifier,
applicationUniversalIdentifier: 'app-standard',
objectMetadataUniversalIdentifier: companyObject.universalIdentifier,
indexType: overrides.indexType,
indexWhereClause: overrides.indexWhereClause ?? null,
isCustom: false,
isUnique: overrides.isUnique,
universalFlatIndexFieldMetadatas: overrides.fieldIds.map((id, order) => ({
createdAt: now,
updatedAt: now,
order,
subFieldName: null,
fieldMetadataUniversalIdentifier: id,
indexMetadataUniversalIdentifier: overrides.universalIdentifier,
})),
});
it('pins names for representative standard-index shapes', () => {
const fields = [
scalarUniqueField,
scalarNonUniqueField,
tsVectorField,
manyToOneRelationField,
];
const scalarUnique = generateFlatIndexMetadataWithNameOrThrow({
flatObjectMetadata: companyObject,
objectFlatFieldMetadatas: fields,
flatIndex: buildIndex({
universalIdentifier: 'idx-scalar-unique',
isUnique: true,
indexType: IndexType.BTREE,
fieldIds: [scalarUniqueField.universalIdentifier],
}),
});
const scalarNonUnique = generateFlatIndexMetadataWithNameOrThrow({
flatObjectMetadata: companyObject,
objectFlatFieldMetadatas: fields,
flatIndex: buildIndex({
universalIdentifier: 'idx-scalar-non-unique',
isUnique: false,
indexType: IndexType.BTREE,
fieldIds: [scalarNonUniqueField.universalIdentifier],
}),
});
const searchVectorGin = generateFlatIndexMetadataWithNameOrThrow({
flatObjectMetadata: companyObject,
objectFlatFieldMetadatas: fields,
flatIndex: buildIndex({
universalIdentifier: 'idx-search-vector',
isUnique: false,
indexType: IndexType.GIN,
fieldIds: [tsVectorField.universalIdentifier],
}),
});
const manyToOneJoin = generateFlatIndexMetadataWithNameOrThrow({
flatObjectMetadata: companyObject,
objectFlatFieldMetadatas: fields,
flatIndex: buildIndex({
universalIdentifier: 'idx-account-owner',
isUnique: false,
indexType: IndexType.BTREE,
fieldIds: [manyToOneRelationField.universalIdentifier],
}),
});
const partialUnique = generateFlatIndexMetadataWithNameOrThrow({
flatObjectMetadata: companyObject,
objectFlatFieldMetadatas: fields,
flatIndex: buildIndex({
universalIdentifier: 'idx-scalar-unique-partial',
isUnique: true,
indexType: IndexType.BTREE,
fieldIds: [scalarUniqueField.universalIdentifier],
indexWhereClause: '"deletedAt" IS NULL',
}),
});
expect({
scalarUnique: scalarUnique.name,
scalarNonUnique: scalarNonUnique.name,
searchVectorGin: searchVectorGin.name,
manyToOneJoin: manyToOneJoin.name,
partialUnique: partialUnique.name,
}).toMatchSnapshot();
});
});
@@ -0,0 +1,12 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { computeUniqueFieldMetadataIdsFromIndexes } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-field-metadata-ids-from-indexes.util';
export const computeUniqueFieldMetadataIdsFromFlatIndexMaps = (
flatIndexMaps: FlatEntityMaps<FlatIndexMetadata>,
): Set<string> =>
computeUniqueFieldMetadataIdsFromIndexes(
Object.values(flatIndexMaps.byUniversalIdentifier).filter(isDefined),
);
@@ -0,0 +1,6 @@
import { type IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { computeUniqueFieldMetadataIdsFromIndexes } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-field-metadata-ids-from-indexes.util';
export const computeUniqueFieldMetadataIdsFromIndexEntities = (
indexEntities: ReadonlyArray<IndexMetadataEntity>,
): Set<string> => computeUniqueFieldMetadataIdsFromIndexes(indexEntities);
@@ -0,0 +1,33 @@
type IndexLike = {
isUnique: boolean;
flatIndexFieldMetadatas?: Array<{
fieldMetadataId: string;
subFieldName: string | null;
}>;
indexFieldMetadatas?: Array<{
fieldMetadataId: string;
subFieldName: string | null;
}>;
};
// A field is "unique" iff there exists a UNIQUE IndexMetadata whose single
// member is exactly that field (no composite sub-field). Centralized so the
// cache builder, REST controller, and any future consumer agree on the rule.
export const computeUniqueFieldMetadataIdsFromIndexes = (
indexes: ReadonlyArray<IndexLike>,
): Set<string> => {
const set = new Set<string>();
for (const index of indexes) {
if (!index.isUnique) continue;
const fields = index.flatIndexFieldMetadatas ?? index.indexFieldMetadatas;
if (fields?.length !== 1) continue;
if (fields[0].subFieldName !== null) continue;
set.add(fields[0].fieldMetadataId);
}
return set;
};
@@ -10,11 +10,16 @@ type GenerateDeterministicIndexNameArgs = {
>;
isUnique?: boolean;
orderedIndexColumnNames: string[];
// Include the WHERE clause in the hash so a partial index on the same
// columns doesn't collide with the non-partial one (Postgres lets them
// coexist; the unique-name constraint must not block that).
indexWhereClause?: string | null;
};
export const generateDeterministicIndexNameV2 = ({
orderedIndexColumnNames,
flatObjectMetadata,
isUnique = false,
indexWhereClause,
}: GenerateDeterministicIndexNameArgs): string => {
const hash = createHash('sha256');
@@ -27,5 +32,9 @@ export const generateDeterministicIndexNameV2 = ({
hash.update(column);
});
if (indexWhereClause) {
hash.update(indexWhereClause);
}
return `IDX_${isUnique ? 'UNIQUE_' : ''}${hash.digest('hex').slice(0, 27)}`;
};
@@ -1,11 +1,13 @@
import { RelationType } from 'twenty-shared/types';
import { compositeTypeDefinitions, RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { isMorphOrRelationUniversalFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { generateDeterministicIndexNameV2 } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name-v2';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
@@ -17,12 +19,13 @@ export type GenerateFlatIndexArgs = {
objectFlatFieldMetadatas: UniversalFlatFieldMetadata[];
flatIndex: Omit<UniversalFlatIndexMetadata, 'name'>;
};
export const generateFlatIndexMetadataWithNameOrThrow = ({
flatObjectMetadata,
objectFlatFieldMetadatas,
flatIndex,
}: GenerateFlatIndexArgs): UniversalFlatIndexMetadata => {
const orderedFlatFields = flatIndex.universalFlatIndexFieldMetadatas
const orderedIndexColumnNames = flatIndex.universalFlatIndexFieldMetadatas
.sort((a, b) => a.order - b.order)
.map((flatIndexField) => {
const relatedFlatFieldMetadata = objectFlatFieldMetadatas.find(
@@ -38,36 +41,59 @@ export const generateFlatIndexMetadataWithNameOrThrow = ({
);
}
// Composite parent with an explicit sub-field → single sub-column.
// Composite parent without sub-field falls through to the legacy
// scalar branch below, which produces a deterministic name based on
// the parent name (the runner handles the multi-column SQL expansion
// via isIncludedInUniqueConstraint).
if (
isCompositeFieldMetadataType(relatedFlatFieldMetadata.type) &&
isDefined(flatIndexField.subFieldName)
) {
const property = compositeTypeDefinitions
.get(relatedFlatFieldMetadata.type)
?.properties.find(
(compositeProperty) =>
compositeProperty.name === flatIndexField.subFieldName,
);
if (!isDefined(property)) {
throw new FlatEntityMapsException(
`Composite sub-field "${flatIndexField.subFieldName}" not found on ${relatedFlatFieldMetadata.name}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
return computeCompositeColumnName(
{
name: relatedFlatFieldMetadata.name,
type: relatedFlatFieldMetadata.type,
},
property,
);
}
const isManyToOneRelation =
isMorphOrRelationUniversalFlatFieldMetadata(relatedFlatFieldMetadata) &&
relatedFlatFieldMetadata.universalSettings?.relationType ===
RelationType.MANY_TO_ONE;
const name = isManyToOneRelation
return isManyToOneRelation
? computeMorphOrRelationFieldJoinColumnName({
name: relatedFlatFieldMetadata.name,
})
: relatedFlatFieldMetadata.name;
return {
name,
isUnique: relatedFlatFieldMetadata.isUnique,
};
});
const isUnique = orderedFlatFields.some((flatField) => flatField.isUnique);
const orderedIndexColumnNames = orderedFlatFields.map(
(flatField) => flatField.name,
);
const name = generateDeterministicIndexNameV2({
flatObjectMetadata,
orderedIndexColumnNames,
isUnique,
isUnique: flatIndex.isUnique,
indexWhereClause: flatIndex.indexWhereClause,
});
return {
...flatIndex,
name,
isUnique,
};
};
@@ -0,0 +1,48 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ConflictError,
ForbiddenError,
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
IndexMetadataException,
IndexMetadataExceptionCode,
} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { workspaceMigrationBuilderGraphqlApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-graphql-api-exception-handler.util';
export const indexMetadataGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof WorkspaceMigrationBuilderException) {
return workspaceMigrationBuilderGraphqlApiExceptionHandler(error);
}
if (error instanceof IndexMetadataException) {
switch (error.code) {
case IndexMetadataExceptionCode.INDEX_OBJECT_NOT_FOUND:
case IndexMetadataExceptionCode.INDEX_NOT_FOUND:
throw new NotFoundError(error);
case IndexMetadataExceptionCode.INDEX_FIELDS_REQUIRED:
case IndexMetadataExceptionCode.DUPLICATE_INDEX_FIELDS:
case IndexMetadataExceptionCode.INDEX_FIELD_NOT_FOUND_ON_OBJECT:
case IndexMetadataExceptionCode.INDEX_NOT_SUPPORTED_FOR_COMPOSITE_FIELD:
case IndexMetadataExceptionCode.INDEX_NOT_SUPPORTED_FOR_MORH_RELATION_FIELD_AND_RELATION_FIELD:
case IndexMetadataExceptionCode.INDEX_TYPE_NOT_SUPPORTED_FOR_FIELD_TYPE:
case IndexMetadataExceptionCode.DUPLICATE_UNIQUE_INDEX:
throw new UserInputError(error);
case IndexMetadataExceptionCode.CANNOT_DELETE_SYSTEM_INDEX:
throw new ForbiddenError(error);
case IndexMetadataExceptionCode.CUSTOM_INDEX_LIMIT_REACHED:
throw new ConflictError(error);
case IndexMetadataExceptionCode.INDEX_CREATION_FAILED:
throw new InternalServerError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -0,0 +1,54 @@
import { msg } from '@lingui/core/macro';
import { GIN_COMPATIBLE_FIELD_TYPES } from 'twenty-shared/constants';
import { type FieldMetadataType } from 'twenty-shared/types';
import {
IndexMetadataException,
IndexMetadataExceptionCode,
} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
type IndexFieldForValidation = {
type: FieldMetadataType;
name: string;
label: string;
subFieldName: string | null;
};
// GIN requires an operator class for each column. Postgres ships a default
// opclass only for jsonb (RAW_JSON), text[]/varchar[] (ARRAY, MULTI_SELECT),
// and tsvector (TS_VECTOR). Composite parents resolve to scalar text/numeric
// sub-columns, none of which have a default GIN opclass.
export const validateIndexTypeAgainstFieldsOrThrow = ({
indexType,
fields,
}: {
indexType: IndexType;
fields: IndexFieldForValidation[];
}): void => {
if (indexType !== IndexType.GIN) {
return;
}
for (const field of fields) {
if (field.subFieldName !== null) {
throw new IndexMetadataException(
`GIN index does not support composite sub-property ${field.name}.${field.subFieldName}`,
IndexMetadataExceptionCode.INDEX_TYPE_NOT_SUPPORTED_FOR_FIELD_TYPE,
{
userFriendlyMessage: msg`GIN indexes work on multi-select, array, JSON, and search-vector columns. "${field.label}" sub-properties don't qualify.`,
},
);
}
if (!GIN_COMPATIBLE_FIELD_TYPES.has(field.type)) {
throw new IndexMetadataException(
`GIN index does not support field ${field.name} of type ${field.type}`,
IndexMetadataExceptionCode.INDEX_TYPE_NOT_SUPPORTED_FOR_FIELD_TYPE,
{
userFriendlyMessage: msg`GIN indexes work on multi-select, array, JSON, and search-vector columns. "${field.label}" doesn't qualify.`,
},
);
}
}
};
@@ -0,0 +1,63 @@
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import {
IndexMetadataException,
IndexMetadataExceptionCode,
} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception';
type ProposedUniqueIndex = {
isUnique: boolean;
fields: Array<{ fieldMetadataId: string; subFieldName: string | null }>;
};
// Field-level uniqueness is now expressed exclusively as a single-field
// UNIQUE IndexMetadata. Two unique indexes on the same single (field,
// subFieldName) pair are redundant and waste write throughput, so reject.
export const validateNoDuplicateUniqueIndexOrThrow = ({
proposed,
existingFlatIndexMaps,
objectMetadataId,
ignoreIndexId,
}: {
proposed: ProposedUniqueIndex;
existingFlatIndexMaps: FlatEntityMaps<FlatIndexMetadata>;
objectMetadataId: string;
ignoreIndexId?: string;
}): void => {
if (!proposed.isUnique || proposed.fields.length !== 1) {
return;
}
const [proposedField] = proposed.fields;
const duplicate = Object.values(
existingFlatIndexMaps.byUniversalIdentifier,
).find((flatIndex) => {
if (!isDefined(flatIndex)) return false;
if (flatIndex.id === ignoreIndexId) return false;
if (!flatIndex.isUnique) return false;
if (flatIndex.objectMetadataId !== objectMetadataId) return false;
if (flatIndex.flatIndexFieldMetadatas.length !== 1) return false;
const existingField = flatIndex.flatIndexFieldMetadatas[0];
return (
existingField.fieldMetadataId === proposedField.fieldMetadataId &&
(existingField.subFieldName ?? null) ===
(proposedField.subFieldName ?? null)
);
});
if (isDefined(duplicate)) {
throw new IndexMetadataException(
`A UNIQUE index already covers this column (${duplicate.name})`,
IndexMetadataExceptionCode.DUPLICATE_UNIQUE_INDEX,
{
userFriendlyMessage: msg`This column is already marked as unique. Toggle the field's "Unique" off first if you want to manage the constraint here.`,
},
);
}
};