diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-field-metadata-ids-from-indexes.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-field-metadata-ids-from-indexes.util.spec.ts new file mode 100644 index 0000000000..e3567ca641 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/compute-unique-field-metadata-ids-from-indexes.util.spec.ts @@ -0,0 +1,90 @@ +import { computeUniqueFieldMetadataIdsFromIndexes } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-field-metadata-ids-from-indexes.util'; + +const FIELD_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const FIELD_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + +describe('computeUniqueFieldMetadataIdsFromIndexes', () => { + it('includes a field backed by a single-column system-side-effect unique index', () => { + const result = computeUniqueFieldMetadataIdsFromIndexes([ + { + isUnique: true, + isSystemSideEffect: true, + flatIndexFieldMetadatas: [ + { fieldMetadataId: FIELD_A, subFieldName: null }, + ], + }, + ]); + + expect(result.has(FIELD_A)).toBe(true); + }); + + it('excludes a single-column unique index that is not a system side effect (e.g. a user defineIndex)', () => { + const result = computeUniqueFieldMetadataIdsFromIndexes([ + { + isUnique: true, + isSystemSideEffect: false, + flatIndexFieldMetadatas: [ + { fieldMetadataId: FIELD_A, subFieldName: null }, + ], + }, + ]); + + expect(result.has(FIELD_A)).toBe(false); + }); + + it('excludes a non-unique index', () => { + const result = computeUniqueFieldMetadataIdsFromIndexes([ + { + isUnique: false, + isSystemSideEffect: true, + flatIndexFieldMetadatas: [ + { fieldMetadataId: FIELD_A, subFieldName: null }, + ], + }, + ]); + + expect(result.has(FIELD_A)).toBe(false); + }); + + it('excludes fields that are part of a multi-column unique index', () => { + const result = computeUniqueFieldMetadataIdsFromIndexes([ + { + isUnique: true, + isSystemSideEffect: true, + flatIndexFieldMetadatas: [ + { fieldMetadataId: FIELD_A, subFieldName: null }, + { fieldMetadataId: FIELD_B, subFieldName: null }, + ], + }, + ]); + + expect(result.has(FIELD_A)).toBe(false); + expect(result.has(FIELD_B)).toBe(false); + }); + + it('excludes a unique index on a composite sub-field', () => { + const result = computeUniqueFieldMetadataIdsFromIndexes([ + { + isUnique: true, + isSystemSideEffect: true, + flatIndexFieldMetadatas: [ + { fieldMetadataId: FIELD_A, subFieldName: 'primaryLinkUrl' }, + ], + }, + ]); + + expect(result.has(FIELD_A)).toBe(false); + }); + + it('supports the entity-shaped indexFieldMetadatas key', () => { + const result = computeUniqueFieldMetadataIdsFromIndexes([ + { + isUnique: true, + isSystemSideEffect: true, + indexFieldMetadatas: [{ fieldMetadataId: FIELD_A, subFieldName: null }], + }, + ]); + + expect(result.has(FIELD_A)).toBe(true); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-field-metadata-ids-from-indexes.util.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-field-metadata-ids-from-indexes.util.ts index 7b33715a7e..e7d6d8804a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-field-metadata-ids-from-indexes.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/compute-unique-field-metadata-ids-from-indexes.util.ts @@ -1,5 +1,6 @@ type IndexLike = { isUnique: boolean; + isSystemSideEffect: boolean; flatIndexFieldMetadatas?: Array<{ fieldMetadataId: string; subFieldName: string | null; @@ -10,9 +11,24 @@ type IndexLike = { }>; }; -// 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. +// A field is "unique" iff a UNIQUE IndexMetadata that the engine owns as the +// field's backing constraint (isSystemSideEffect) has exactly that field as its +// single member (no composite sub-field). +// +// The isSystemSideEffect guard is what keeps `field.isUnique` symmetric between +// the app-sync "from" side (this derivation, over the workspace cache) and the +// "to" side (the field-level flag on the manifest). A user-declared custom +// UNIQUE index — e.g. `defineIndex({ isUnique: true, fields: [oneField] })` — +// is isSystemSideEffect: false: it enforces uniqueness at the DB level but is +// not the field's backing constraint, so it must NOT flip `field.isUnique`. +// Counting it would make the cache report `true` while the manifest field +// reports `false`, producing a phantom `[isUnique] changed` diff that reapplies +// on every sync and never converges. Excluding it also stops the create/update +// side-effect from generating a second, duplicate backing index for a field +// whose uniqueness the custom index already provides. +// +// Centralized so the cache builder, REST controller, and any future consumer +// agree on the rule. export const computeUniqueFieldMetadataIdsFromIndexes = ( indexes: ReadonlyArray, ): Set => { @@ -20,6 +36,7 @@ export const computeUniqueFieldMetadataIdsFromIndexes = ( for (const index of indexes) { if (!index.isUnique) continue; + if (!index.isSystemSideEffect) continue; const fields = index.flatIndexFieldMetadatas ?? index.indexFieldMetadatas;