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:
Marie
2026-06-29 16:04:40 +02:00
committed by GitHub
parent 6fa803edc2
commit 4eb28b73c7
7 changed files with 773 additions and 0 deletions
@@ -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',
});
});
});
@@ -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;
};