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:
+11
@@ -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",
|
||||
}
|
||||
`;
|
||||
+148
@@ -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();
|
||||
});
|
||||
});
|
||||
+12
@@ -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),
|
||||
);
|
||||
+6
@@ -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);
|
||||
+33
@@ -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;
|
||||
};
|
||||
+9
@@ -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)}`;
|
||||
};
|
||||
|
||||
+41
-15
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+48
@@ -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;
|
||||
};
|
||||
+54
@@ -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.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
+63
@@ -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.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user