fix(server): normalize legacy index names (command) (#22053)
## TL;DR Adds a workspace upgrade command that normalizes index names to the current v2 deterministic naming convention (IDX_ prefix). This will close https://github.com/twentyhq/twenty/issues/21383 : uniqueness constraint cannot be disabled (for users who set it before v2 determinist naming is enforced). ### Background The deterministic index name embeds the table name, columns, uniqueness and where clause. The naming convention changed on **2025-09-23** (#14567 - added the `IDX_`/`IDX_UNIQUE_` prefix and folded the table name into the hash). Index rows created before that kept their old name in `core."indexMetadata"`, and nothing rewrites it (only targeted phone/relation rebuilds got new names). Code paths that locate an index by recomputing its expected name then miss these legacy-named rows. The most visible symptom is #21383: toggling a field's `isUnique` from `true` → `false` recomputes the expected unique-index name, fails to find the legacy-named index, and silently no-ops — so uniqueness can't be disabled. ### What the command does Per workspace, for each index: - recomputes the expected name with the same generator the app uses (`generateFlatIndexMetadataWithNameOrThrow`); - if the stored name differs → **rename** it (metadata `UPDATE` + a new metadata-only `ALTER INDEX … RENAME`, which preserves the unique constraint with no rebuild/lock); - if a correctly-named twin already exists (the legacy + freshly-generated duplicate case) → **drop the redundant** one (physical + metadata, field rows cascade) and keep the canonical; - invalidates the metadata cache so the running app + `isUnique` derivation reflect the new names. Honors `--dry-run`, wraps writes in a transaction (rolls back on error), and skips (with a warning) any single index whose name can't be recomputed so it can't abort the whole workspace. ### Notable changes - New `renameIndex` on `WorkspaceSchemaIndexManagerService` (`ALTER INDEX IF EXISTS … RENAME`). - Planning logic extracted into a pure, unit-tested util (`planIndexNameNormalization`).
This commit is contained in:
+169
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
type FlatIndexNameStatus,
|
||||
planIndexNameNormalization,
|
||||
} from 'src/database/commands/upgrade-version-command/2-18/utils/plan-index-name-normalization.util';
|
||||
|
||||
const buildStatus = (
|
||||
overrides: Partial<FlatIndexNameStatus> &
|
||||
Pick<FlatIndexNameStatus, 'indexMetadataId' | 'currentName' | 'expectedName'>,
|
||||
): FlatIndexNameStatus => ({
|
||||
objectMetadataId: 'object-1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('planIndexNameNormalization', () => {
|
||||
it('returns no operations for an empty input', () => {
|
||||
expect(planIndexNameNormalization([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no operations when every index name already matches', () => {
|
||||
const statuses = [
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-1',
|
||||
currentName: 'IDX_UNIQUE_aaa',
|
||||
expectedName: 'IDX_UNIQUE_aaa',
|
||||
}),
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-2',
|
||||
currentName: 'IDX_bbb',
|
||||
expectedName: 'IDX_bbb',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(planIndexNameNormalization(statuses)).toEqual([]);
|
||||
});
|
||||
|
||||
it('renames a single legacy-named index to its expected name', () => {
|
||||
const statuses = [
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-1',
|
||||
currentName: 'legacyhash000000000000000000',
|
||||
expectedName: 'IDX_UNIQUE_newhash',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(planIndexNameNormalization(statuses)).toEqual([
|
||||
{
|
||||
type: 'rename',
|
||||
indexMetadataId: 'index-1',
|
||||
objectMetadataId: 'object-1',
|
||||
fromName: 'legacyhash000000000000000000',
|
||||
toName: 'IDX_UNIQUE_newhash',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('renames the survivor and drops the duplicate when two legacy indexes resolve to the same name', () => {
|
||||
const statuses = [
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-legacy-a',
|
||||
currentName: 'legacyhashA',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
}),
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-legacy-b',
|
||||
currentName: 'legacyhashB',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(planIndexNameNormalization(statuses)).toEqual([
|
||||
{
|
||||
type: 'rename',
|
||||
indexMetadataId: 'index-legacy-a',
|
||||
objectMetadataId: 'object-1',
|
||||
fromName: 'legacyhashA',
|
||||
toName: 'IDX_UNIQUE_shared',
|
||||
},
|
||||
{
|
||||
type: 'dropRedundant',
|
||||
indexMetadataId: 'index-legacy-b',
|
||||
objectMetadataId: 'object-1',
|
||||
redundantName: 'legacyhashB',
|
||||
keptName: 'IDX_UNIQUE_shared',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the already-correct twin and only drops the legacy duplicate (no rename)', () => {
|
||||
const statuses = [
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-legacy',
|
||||
currentName: 'legacyhash',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
}),
|
||||
buildStatus({
|
||||
indexMetadataId: 'index-correct',
|
||||
currentName: 'IDX_UNIQUE_shared',
|
||||
expectedName: 'IDX_UNIQUE_shared',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(planIndexNameNormalization(statuses)).toEqual([
|
||||
{
|
||||
type: 'dropRedundant',
|
||||
indexMetadataId: 'index-legacy',
|
||||
objectMetadataId: 'object-1',
|
||||
redundantName: 'legacyhash',
|
||||
keptName: 'IDX_UNIQUE_shared',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles a mix of correct, legacy, and duplicate indexes across objects', () => {
|
||||
const statuses = [
|
||||
// already correct -> ignored
|
||||
buildStatus({
|
||||
indexMetadataId: 'ok',
|
||||
objectMetadataId: 'object-a',
|
||||
currentName: 'IDX_ok',
|
||||
expectedName: 'IDX_ok',
|
||||
}),
|
||||
// legacy single -> rename
|
||||
buildStatus({
|
||||
indexMetadataId: 'legacy-single',
|
||||
objectMetadataId: 'object-a',
|
||||
currentName: 'oldname',
|
||||
expectedName: 'IDX_renamed',
|
||||
}),
|
||||
// duplicate pair on another object -> rename + drop
|
||||
buildStatus({
|
||||
indexMetadataId: 'dup-1',
|
||||
objectMetadataId: 'object-b',
|
||||
currentName: 'oldDup1',
|
||||
expectedName: 'IDX_UNIQUE_dup',
|
||||
}),
|
||||
buildStatus({
|
||||
indexMetadataId: 'dup-2',
|
||||
objectMetadataId: 'object-b',
|
||||
currentName: 'oldDup2',
|
||||
expectedName: 'IDX_UNIQUE_dup',
|
||||
}),
|
||||
];
|
||||
|
||||
const operations = planIndexNameNormalization(statuses);
|
||||
|
||||
expect(operations).toHaveLength(3);
|
||||
expect(operations).toContainEqual({
|
||||
type: 'rename',
|
||||
indexMetadataId: 'legacy-single',
|
||||
objectMetadataId: 'object-a',
|
||||
fromName: 'oldname',
|
||||
toName: 'IDX_renamed',
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
type: 'rename',
|
||||
indexMetadataId: 'dup-1',
|
||||
objectMetadataId: 'object-b',
|
||||
fromName: 'oldDup1',
|
||||
toName: 'IDX_UNIQUE_dup',
|
||||
});
|
||||
expect(operations).toContainEqual({
|
||||
type: 'dropRedundant',
|
||||
indexMetadataId: 'dup-2',
|
||||
objectMetadataId: 'object-b',
|
||||
redundantName: 'oldDup2',
|
||||
keptName: 'IDX_UNIQUE_dup',
|
||||
});
|
||||
});
|
||||
});
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
export type FlatIndexNameStatus = {
|
||||
indexMetadataId: string;
|
||||
objectMetadataId: string;
|
||||
currentName: string;
|
||||
expectedName: string;
|
||||
};
|
||||
|
||||
export type RenameIndexNameOperation = {
|
||||
type: 'rename';
|
||||
indexMetadataId: string;
|
||||
objectMetadataId: string;
|
||||
fromName: string;
|
||||
toName: string;
|
||||
};
|
||||
|
||||
export type DropRedundantIndexOperation = {
|
||||
type: 'dropRedundant';
|
||||
indexMetadataId: string;
|
||||
objectMetadataId: string;
|
||||
redundantName: string;
|
||||
keptName: string;
|
||||
};
|
||||
|
||||
export type IndexNameNormalizationOperation =
|
||||
| RenameIndexNameOperation
|
||||
| DropRedundantIndexOperation;
|
||||
|
||||
export const planIndexNameNormalization = (
|
||||
indexStatuses: FlatIndexNameStatus[],
|
||||
): IndexNameNormalizationOperation[] => {
|
||||
const operations: IndexNameNormalizationOperation[] = [];
|
||||
|
||||
const statusesByExpectedName = new Map<string, FlatIndexNameStatus[]>();
|
||||
|
||||
for (const status of indexStatuses) {
|
||||
const group = statusesByExpectedName.get(status.expectedName) ?? [];
|
||||
|
||||
group.push(status);
|
||||
statusesByExpectedName.set(status.expectedName, group);
|
||||
}
|
||||
|
||||
for (const [expectedName, group] of statusesByExpectedName) {
|
||||
const survivor =
|
||||
group.find((status) => status.currentName === expectedName) ?? group[0];
|
||||
|
||||
if (survivor.currentName !== expectedName) {
|
||||
operations.push({
|
||||
type: 'rename',
|
||||
indexMetadataId: survivor.indexMetadataId,
|
||||
objectMetadataId: survivor.objectMetadataId,
|
||||
fromName: survivor.currentName,
|
||||
toName: expectedName,
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of group) {
|
||||
if (status.indexMetadataId === survivor.indexMetadataId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
operations.push({
|
||||
type: 'dropRedundant',
|
||||
indexMetadataId: status.indexMetadataId,
|
||||
objectMetadataId: status.objectMetadataId,
|
||||
redundantName: status.currentName,
|
||||
keptName: expectedName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
};
|
||||
Reference in New Issue
Block a user